defmodule ExKrb5 do @moduledoc """ Elixir bindings to macOS Kerberos via GSS.framework. Provides native macOS Kerberos integration using Apple's public GSS APIs, including `gss_aapl_initial_cred()` and `gss_aapl_change_password()`. Tickets are stored in the system KCM credential cache, visible to Finder, Safari, and all other macOS applications. This is the same approach used by Jamf's NoMAD — no shelling out to `kinit`, no private APIs. ## Quick Start # Authenticate (equivalent to kinit) :ok = ExKrb5.kinit("user@CORP.EXAMPLE.COM", "password123") # List cached credentials {:ok, creds} = ExKrb5.list_credentials() # Check ticket status {:ok, info} = ExKrb5.credential_info("user@CORP.EXAMPLE.COM") info.time_remaining #=> 36000 (seconds) # Change AD password :ok = ExKrb5.change_password("user@CORP.EXAMPLE.COM", "old_pass", "new_pass") # Generate a Kerberos token for HTTP Negotiate auth {:ok, token} = ExKrb5.init_sec_context("user@CORP.EXAMPLE.COM", "HTTP/intranet.corp.example.com") ## macOS Integration All operations go through Apple's `GSS.framework`, which talks to the system `GSSCred` daemon via Mach IPC. This means: - Tickets land in the system KCM credential cache - `klist` on the command line shows tickets acquired by this library - Finder can use the TGT to mount SMB shares - Safari can use the TGT for SPNEGO authentication - The GSSCred daemon handles automatic renewal when credentials are held ## Platform macOS only. Requires GSS.framework (available since macOS 10.7). """ use Rustler, otp_app: :ex_krb5, crate: "ex_krb5" @typedoc "Kerberos principal name (e.g., \"user@REALM\")" @type principal :: String.t() @typedoc "Service principal name (e.g., \"HTTP/host.example.com\")" @type spn :: String.t() @typedoc "Credential information map" @type credential_info :: %{ principal: String.t(), time_remaining: non_neg_integer(), is_expired: boolean() } # ============================================================ # Authentication — kinit equivalent # ============================================================ @doc """ Acquire a Kerberos TGT using a password. Equivalent to `kinit principal`. Uses `gss_aapl_initial_cred()` to authenticate against the KDC and store the resulting TGT in the system credential cache. ## Options * `:forwardable` - Request a forwardable ticket (default: `true`) * `:renewable` - Request a renewable ticket (default: `true`) ## Examples iex> ExKrb5.kinit("user@CORP.EXAMPLE.COM", "password123") :ok iex> ExKrb5.kinit("user@CORP.EXAMPLE.COM", "wrong_password") {:error, "KDC: Preauthentication failed"} """ @spec kinit(principal(), String.t(), keyword()) :: :ok | {:error, String.t()} def kinit(_principal, _password, _opts \\ []), do: :erlang.nif_error(:not_loaded) # ============================================================ # Password Management # ============================================================ @doc """ Change a Kerberos password. Uses `gss_aapl_change_password()` to change the password on the KDC. The old password must be correct. The new password must meet the KDC's password policy requirements. ## Examples iex> ExKrb5.change_password("user@CORP.EXAMPLE.COM", "old_pass", "new_pass") :ok iex> ExKrb5.change_password("user@CORP.EXAMPLE.COM", "old_pass", "weak") {:error, "Password does not meet policy requirements"} """ @spec change_password(principal(), String.t(), String.t()) :: :ok | {:error, String.t()} def change_password(_principal, _old_password, _new_password), do: :erlang.nif_error(:not_loaded) # ============================================================ # Credential Cache Operations # ============================================================ @doc """ List all Kerberos credentials in the system cache. Uses `gss_iter_creds_f()` to enumerate all cached credentials. Returns a list of credential info maps. ## Examples iex> ExKrb5.list_credentials() {:ok, [ %{principal: "user@CORP.EXAMPLE.COM", time_remaining: 36000, is_expired: false} ]} """ @spec list_credentials() :: {:ok, [credential_info()]} | {:error, String.t()} def list_credentials(), do: :erlang.nif_error(:not_loaded) @doc """ Get information about a specific credential. ## Examples iex> ExKrb5.credential_info("user@CORP.EXAMPLE.COM") {:ok, %{principal: "user@CORP.EXAMPLE.COM", time_remaining: 36000, is_expired: false}} iex> ExKrb5.credential_info("nobody@NOWHERE") {:error, "No credentials found for principal"} """ @spec credential_info(principal()) :: {:ok, credential_info()} | {:error, String.t()} def credential_info(_principal), do: :erlang.nif_error(:not_loaded) # ============================================================ # Credential Lifecycle # ============================================================ @doc """ Renew a Kerberos TGT. > Note: Not currently available via public GSS.framework API. > Use `kinit/2` to re-authenticate, or enable the Kerberos SSO > Extension for automatic renewal. ## Examples iex> ExKrb5.renew("user@CORP.EXAMPLE.COM") {:error, "Renewal requires re-authentication..."} """ @spec renew(principal()) :: :ok | {:error, String.t()} def renew(_principal), do: :erlang.nif_error(:not_loaded) @doc """ Hold (pin) a credential in the cache. > Note: `gss_cred_hold` is not available in the public GSS.framework API. > Credentials acquired via `kinit/2` are automatically managed by > the GSSCred daemon. """ @spec hold(principal()) :: :ok | {:error, String.t()} def hold(_principal), do: :erlang.nif_error(:not_loaded) @doc """ Unhold (unpin) a credential. > Note: `gss_cred_unhold` is not available in the public GSS.framework API. """ @spec unhold(principal()) :: :ok | {:error, String.t()} def unhold(_principal), do: :erlang.nif_error(:not_loaded) @doc """ Destroy a credential from the cache. Equivalent to `kdestroy` for a specific principal. ## Examples iex> ExKrb5.destroy("user@CORP.EXAMPLE.COM") :ok """ @spec destroy(principal()) :: :ok | {:error, String.t()} def destroy(_principal), do: :erlang.nif_error(:not_loaded) # ============================================================ # GSSAPI Security Context # ============================================================ @doc """ Initialize a GSSAPI security context (generate a Kerberos token). Generates a token that can be used for HTTP Negotiate authentication, SMB authentication, etc. The `target` should be a service principal name like `"HTTP/intranet.corp.example.com"` or `"cifs/fileserver.corp.example.com"`. ## Options * `:delegate` - Allow credential delegation (default: `false`) * `:mutual` - Request mutual authentication (default: `false`) ## Examples iex> {:ok, token} = ExKrb5.init_sec_context("user@CORP.EXAMPLE.COM", "HTTP/intranet.corp.example.com") iex> byte_size(token) > 0 true # Use in HTTP header: # Authorization: Negotiate \#{Base.encode64(token)} """ @spec init_sec_context(principal(), spn(), keyword()) :: {:ok, binary()} | {:error, String.t()} def init_sec_context(_principal, _target, _opts \\ []), do: :erlang.nif_error(:not_loaded) # ============================================================ # Convenience / Query # ============================================================ @doc """ Check if a valid (non-expired) TGT exists for the given principal. ## Examples iex> ExKrb5.has_valid_tgt?("user@CORP.EXAMPLE.COM") true """ @spec has_valid_tgt?(principal()) :: boolean() def has_valid_tgt?(principal) do case credential_info(principal) do {:ok, %{is_expired: false}} -> true _ -> false end end @doc """ Get the time remaining (in seconds) for a credential. Returns `0` if no valid credential is found. ## Examples iex> ExKrb5.time_remaining("user@CORP.EXAMPLE.COM") 36000 """ @spec time_remaining(principal()) :: non_neg_integer() def time_remaining(principal) do case credential_info(principal) do {:ok, %{time_remaining: t}} -> t _ -> 0 end end end