defmodule GitGud.Ssh.KeyApi do
@moduledoc """
`:ssh_server_key_api` implementation that authenticates clients
against the `user_ssh_keys` table.
Identity model: the SSH username MUST be the user's handle (the
local-part of their email). The presented public key MUST be one
that user has registered. We can't bridge state between OTP's
`is_auth_key` and the channel handler, so we use the SSH username
itself as the identity carrier — the channel reads it back via
`:ssh.connection_info/2`. Same approach Gerrit takes.
Deploy keys aren't yet supported over the embedded daemon; that's a
follow-up. They still work over external OpenSSH via the
`priv/scripts/gitgud-ssh` wrapper.
"""
@behaviour :ssh_server_key_api
require Logger
alias GitGud.SshKeys
# ── host key ────────────────────────────────────────────────────────
@impl true
def host_key(algorithm, opts), do: :ssh_file.host_key(algorithm, opts)
# ── client auth ─────────────────────────────────────────────────────
@impl true
def is_auth_key(public_key, user_charlist, _opts) do
user_str = to_string(user_charlist)
fp = fingerprint_of(public_key)
case SshKeys.get_by_fingerprint(fp) do
%{user_id: uid} = key ->
user = GitGud.Repo.get(GitGud.Accounts.User, uid)
if user && handle_of(user) == user_str do
_ = SshKeys.touch(key)
Logger.info("ssh auth ok: user=#{user_str} fp=#{fp}")
true
else
Logger.warning(
"ssh auth reject: fp matches user=#{inspect(handle_of(user))} but ssh user=#{user_str}"
)
false
end
nil ->
Logger.warning("ssh auth reject: unknown key fp=#{fp} user=#{user_str}")
false
end
end
defp handle_of(%GitGud.Accounts.User{email: email}) when is_binary(email),
do: email |> String.split("@", parts: 2) |> List.first()
defp handle_of(_), do: nil
@doc """
Compute the "SHA256:<base64>" fingerprint of a parsed Erlang public
key the way OpenSSH does. Matches what `SshKey.compute_fingerprint/1`
stores when a user pastes a key into the UI.
"""
def fingerprint_of(public_key) do
blob =
try do
:ssh_message.ssh2_pubkey_encode(public_key) |> IO.iodata_to_binary()
rescue
_ -> wire_blob_fallback(public_key)
catch
_, _ -> wire_blob_fallback(public_key)
end
"SHA256:" <> (blob |> then(&:crypto.hash(:sha256, &1)) |> Base.encode64(padding: false))
end
# Hand-rolled SSH wire-format encoder for the two common key types we
# care about. Used only when `ssh_message.ssh2_pubkey_encode/1` is
# unavailable or doesn't recognize a key shape on the OTP version
# we're running.
defp wire_blob_fallback({:eddsa, pub, :ed25519}) when is_binary(pub) do
type = "ssh-ed25519"
<<byte_size(type)::32, type::binary, byte_size(pub)::32, pub::binary>>
end
defp wire_blob_fallback({:RSAPublicKey, n, e}) do
type = "ssh-rsa"
e_bin = mpint(:binary.encode_unsigned(e))
n_bin = mpint(:binary.encode_unsigned(n))
<<byte_size(type)::32, type::binary, byte_size(e_bin)::32, e_bin::binary,
byte_size(n_bin)::32, n_bin::binary>>
end
defp wire_blob_fallback(_), do: <<>>
defp mpint(<<msb::8, _::binary>> = bin) when msb >= 0x80, do: <<0, bin::binary>>
defp mpint(bin), do: bin
end
3.5 KiB · text
5af55d9