Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions lib/kamal/secrets/adapters/keepassxc.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
require "open3"

class Kamal::Secrets::Adapters::Keepassxc < Kamal::Secrets::Adapters::Base
# Usage Example:
# kamal secrets fetch --adapter keepassxc --account ~/path/to/secrets.kdbx --from entry-title KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY ANY_OTHER_ATTRIBUTE_SAVED_IN_ADVANCE_TAB_OF_AN_ENTRY

private

# 1. Login / Authentication
def login(account)
# In CI, we don't authenticate. Return a dummy session.
return "ci-session" if ci_mode?

if ENV["KEEPASS_PWD"] && !ENV["KEEPASS_PWD"].empty?
ENV["KEEPASS_PWD"]
else
ask_for_password(account)
end
end

# 2. Dispatcher
def fetch_secrets(secrets, from:, account:, session:)
if ci_mode?
# CI Mode: Passthrough (Strict check)
secrets.each_with_object({}) do |secret, results|
value = ENV[secret]
raise "Missing ENV secret '#{secret}' in CI mode." if value.nil? || value.empty?
results[secret] = value
end
else
# Local Mode: Fetch secrets.
if secrets.empty?
raise ArgumentError, "No secrets specified. Please list the attribute names you want to fetch."
end
fetch_specified_secrets(secrets, from: from, account: account, session: session)
end
end

# 3. Fetch secrets
def fetch_specified_secrets(secrets, from:, account:, session:)
secrets.each_with_object({}) do |secret, results|
# If asking for "password", use standard field, otherwise use Attribute lookup
attr_flag = (secret == "password") ? [] : ["-a", secret]

results[secret] = run_command("show", account, from, *attr_flag, "-q", "--show-protected", session: session)
end
end

# 4. Check Dependencies
def check_dependencies!
# BYPASS: Don't check for CLI in CI
return if ci_mode?
raise "KeePassXC CLI is not installed" unless cli_installed?
end

def cli_installed?
`keepassxc-cli --version 2> /dev/null`
$?.success?
end

# --- Helpers ---

def ci_mode?
ENV["CI"] == "true" || ENV["GITHUB_ACTIONS"] == "true"
end

def ask_for_password(account)
require "io/console"
File.open("/dev/tty", "r+") do |tty|
tty.getpass("Enter KeePassXC Master Password for #{File.basename(account)}: ")
end
end

def run_command(*args, session:)
cmd = ["keepassxc-cli", *args]
stdout, stderr, status = Open3.capture3(*cmd, stdin_data: session)
raise "KeePassXC Error: #{stderr.strip}" unless status.success?
stdout.strip
end
end
95 changes: 95 additions & 0 deletions test/secrets/keepassxc_adapter_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
require "test_helper"

class KeepassxcAdapterTest < SecretAdapterTestCase
setup do
@keepassxc = Kamal::Secrets::Adapters::Keepassxc.new
end

test "fetch specific secrets in local mode calling CLI" do
with_ci_mode(false) do
@keepassxc.stub :ask_for_password, "dummy_master_pass" do
@keepassxc.stub :run_command, "secret_value" do
secrets = @keepassxc.fetch([ "MY_SECRET" ], account: "/tmp/secrets.kdbx", from: "test-env")
assert_equal({ "MY_SECRET" => "secret_value" }, secrets)
end
end
end
end

test "fetch password field uses no -a flag" do
with_ci_mode(false) do
@keepassxc.stub :ask_for_password, "pass" do
verifier = ->(cmd, account, from, *args, session:) {
if args.include?("-a")
raise "Error: 'password' field should not use -a flag"
end
"pw_value"
}

@keepassxc.stub :run_command, verifier do
secrets = @keepassxc.fetch([ "password" ], account: "/tmp/db.kdbx", from: "entry")
assert_equal({ "password" => "pw_value" }, secrets)
end
end
end
end

test "fetch secrets in CI mode reads from ENV" do
with_ci_mode(true) do
with_env("MY_SECRET" => "env_value") do
secrets = @keepassxc.fetch([ "MY_SECRET" ], account: "ignore", from: "ignore")
assert_equal({ "MY_SECRET" => "env_value" }, secrets)
end
end
end

test "fetch secrets in CI mode fails fast if ENV missing" do
with_ci_mode(true) do
error = assert_raises(RuntimeError) do
@keepassxc.fetch([ "MISSING_SECRET" ], account: "ignore", from: "ignore")
end
assert_match /Missing ENV secret 'MISSING_SECRET' in CI mode/, error.message
end
end

test "login returns dummy session in CI mode" do
with_ci_mode(true) do
assert_equal "ci-session", @keepassxc.send(:login, "account")
end
end

test "dependency check is skipped in CI mode" do
with_ci_mode(true) do
# Should NOT raise even if we force cli_installed? to false
@keepassxc.stub :cli_installed?, false do
assert_nothing_raised { @keepassxc.send(:check_dependencies!) }
end
end
end

test "dependency check raises in local mode if CLI missing" do
with_ci_mode(false) do
@keepassxc.stub :cli_installed?, false do
assert_raises(RuntimeError) { @keepassxc.send(:check_dependencies!) }
end
end
end

private
def with_ci_mode(enabled)
key = "CI"
original = ENV[key]
ENV[key] = enabled ? "true" : nil
yield
ensure
ENV[key] = original
end

def with_env(values)
original = ENV.to_h
values.each { |k, v| ENV[k] = v }
yield
ensure
values.keys.each { |k| ENV[k] = original[k] }
end
end