(** Secure API key storage for Eio applications using XDG directories
This library provides secure storage and retrieval of API keys and credentials
for Eio-based applications. It integrates with the XDG Base Directory
Specification via the xdge library to store credentials in a consistent,
platform-appropriate location.
{b Key Features:}
- Store API keys in XDG-compliant directories with proper permissions
- Support multiple profiles per service (production, staging, development)
- TOML-based storage format for readability and flexibility
- Cmdliner integration for easy command-line usage
- Designed for future Secret Service API integration
{b Security Model:}
Currently, credentials are stored as TOML files in [XDG_CONFIG_HOME/appname/keys/]
with strict filesystem permissions (0o600 - owner read/write only). This follows
common practice for CLI tools and provides reasonable security for single-user
systems.
The design supports future integration with system keychains via the
freedesktop.org Secret Service API (GNOME Keyring, KWallet, KeePassXC)
without breaking the API.
{b Storage Structure:}
Keys are stored in [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] where SERVICE
is the name of the service (e.g., "immiche", "karakeepe"). Each service file
contains one or more named profiles:
{v
[default]
api_key = "abc123..."
base_url = "https://api.example.com"
[production]
api_key = "xyz789..."
base_url = "https://api.prod.example.com"
v}
{b Example Usage:}
{[
open Cmdliner
let main (xdg, _) profile =
Eio_main.run @@ fun env ->
let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
let base_url = Keyeio.Profile.get_required profile ~key:"base_url" in
(* Use credentials to create API client *)
let client = Immiche.create ~sw ~env ~base_url ~api_key () in
(* ... *)
let () =
let xdg_term = Xdge.Cmd.term "myapp" env#fs () in
let key_term = Keyeio.Cmd.term
~app_name:"myapp"
~service:"immiche"
env#fs () in
let cmd = Cmd.v (Cmd.info "myapp")
Term.(const main $ xdg_term $ key_term) in
exit (Cmd.eval cmd)
]}
@see XDG Base Directory Specification
@see Secret Service API *)
(** The main Keyeio context type containing directory paths and configuration.
A value of type [t] represents the keyeio configuration for a specific
application, including the keys directory path and storage backend. *)
type t
(** {1 Exceptions} *)
(** Exception raised when a required key is not found in a profile. *)
exception Key_not_found of string
(** Exception raised when a profile is not found in a service. *)
exception Profile_not_found of string
(** Exception raised when attempting to access invalid TOML structure. *)
exception Invalid_key_file of string
(** {1 Profile} *)
(** A profile represents a set of credentials for one service configuration.
Profiles allow you to store multiple sets of credentials for the same
service. For example, you might have "default", "production", and "staging"
profiles for an API service, each with different API keys and endpoints.
Each profile contains arbitrary key-value pairs stored as strings. Common
keys include "api_key", "base_url", "email", etc., but applications are
free to store any configuration needed. *)
module Profile : sig
(** The type of a credential profile. *)
type t
(** [service t] returns the service name this profile belongs to.
@return The service name (e.g., "immiche", "karakeepe") *)
val service : t -> string
(** [name t] returns the profile name.
@return The profile name (e.g., "default", "production", "staging") *)
val name : t -> string
(** [get t ~key] retrieves a value from the profile.
@param t The profile to query
@param key The key to look up
@return [Some value] if the key exists, [None] otherwise
{b Example:}
{[
match Profile.get profile ~key:"api_key" with
| Some key -> Printf.printf "API key: %s\n" key
| None -> Printf.printf "No API key found\n"
]} *)
val get : t -> key:string -> string option
(** [get_required t ~key] retrieves a value that must exist.
@param t The profile to query
@param key The key to look up
@return The value associated with the key
@raise Key_not_found if the key does not exist
{b Example:}
{[
let api_key = Profile.get_required profile ~key:"api_key" in
(* Use api_key, knowing it exists *)
]} *)
val get_required : t -> key:string -> string
(** [keys t] returns all keys available in this profile.
@param t The profile to query
@return A list of all key names in the profile
{b Example:}
{[
let available = Profile.keys profile in
List.iter (fun k -> Printf.printf "Available key: %s\n" k) available
]} *)
val keys : t -> string list
(** [empty] creates an empty profile with no data.
This is useful when a command doesn't need API keys but must
conform to an interface that expects a profile parameter.
@return An empty profile with empty service name, empty profile name, and no data *)
val empty : t
(** [to_toml t] converts the profile to a TOML table representation.
Returns a TOML table containing all key-value pairs in the profile.
@param t The profile to convert
@return A TOML table representation *)
val to_toml : t -> Toml.Types.table
(** [pp ppf t] pretty prints a profile for debugging.
Displays the service name, profile name, and all key-value pairs.
Sensitive values (keys containing "key", "token", "password") are
masked for security.
@param ppf The formatter to print to
@param t The profile to print *)
val pp : Format.formatter -> t -> unit
end
(** {1 Service} *)
(** A service represents all profiles for a given service.
Services group together multiple profiles for the same API or service.
For example, an "immiche" service might contain "default", "production",
and "staging" profiles, each with their own credentials.
Services are loaded from TOML files in the keys directory, with one file
per service: [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] *)
module Service : sig
(** The type of a service containing multiple profiles. *)
type t
(** [name t] returns the service name.
@return The service name (e.g., "immiche", "karakeepe") *)
val name : t -> string
(** [profile_names t] returns all available profile names for this service.
@param t The service to query
@return A list of profile names (e.g., ["default"; "production"; "staging"])
{b Example:}
{[
let profiles = Service.profile_names service in
Printf.printf "Available profiles: %s\n" (String.concat ", " profiles)
]} *)
val profile_names : t -> string list
(** [get_profile t name] retrieves a specific profile by name.
@param t The service to query
@param name The profile name to retrieve
@return [Some profile] if found, [None] otherwise
{b Example:}
{[
match Service.get_profile service "production" with
| Some prof -> (* Use production profile *)
| None -> failwith "Production profile not configured"
]} *)
val get_profile : t -> string -> Profile.t option
(** [default_profile t] retrieves the "default" profile if it exists.
This is a convenience function equivalent to [get_profile t "default"].
@param t The service to query
@return [Some profile] if a "default" profile exists, [None] otherwise *)
val default_profile : t -> Profile.t option
(** [pp ppf t] pretty prints a service and all its profiles.
@param ppf The formatter to print to
@param t The service to print *)
val pp : Format.formatter -> t -> unit
end
(** {1 Construction} *)
(** [create xdg] creates a Keyeio context from an Xdge context.
The keys are stored in a "keys" subdirectory of the XDG config directory.
For example, if the application is "myapp" and [XDG_CONFIG_HOME] is
[~/.config], keys will be stored in [~/.config/myapp/keys/].
The keys directory is created with permissions 0o700 if it doesn't exist.
@param xdg The Xdge context providing XDG directory paths
@return A new Keyeio context
{b Example:}
{[
let xdg = Xdge.create env#fs "myapp" in
let keyeio = Keyeio.create xdg in
(* Now you can load services and profiles *)
]} *)
val create : Xdge.t -> t
(** {1 Creating Credentials} *)
(** [create_default_keyfile t ~service ~profile ~data] creates a new credential file.
Creates a TOML file at [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] with
the provided profile and key-value pairs. If the file already exists,
it will be loaded and the new profile will be added or updated.
@param t The Keyeio context
@param service The service name (e.g., "karakeepe")
@param profile The profile name (default: "default")
@param data Key-value pairs to store in the profile
@return [Ok ()] on success, [Error (`Msg msg)] on failure
{b Example:}
{[
let data = [
("api_key", "ak1_example_key");
("base_url", "https://api.example.com")
] in
match Keyeio.create_default_keyfile keyeio ~service:"karakeepe"
~profile:"default" ~data with
| Ok () -> Printf.printf "Key file created successfully\n"
| Error (`Msg msg) -> Printf.eprintf "Failed: %s\n" msg
]}
{b Security:} The file is created with permissions 0o600 (owner read/write only). *)
val create_default_keyfile :
t ->
service:string ->
profile:string ->
data:(string * string) list ->
(unit, [> `Msg of string ]) result
(** {1 Loading Credentials} *)
(** [load_service t ~service] loads all profiles for a given service.
Reads the TOML file [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] and
parses all profiles contained within. The file must contain TOML tables
where each section name is a profile name containing credential key-value pairs.
@param t The Keyeio context
@param service The service name to load (e.g., "immiche", "karakeepe")
@return [Ok service] on success, [Error (`Msg msg)] on failure
{b Example:}
{[
match Keyeio.load_service keyeio ~service:"immiche" with
| Ok svc ->
begin match Service.default_profile svc with
| Some prof -> (* Use default profile *)
| None -> failwith "No default profile"
end
| Error (`Msg msg) ->
Printf.eprintf "Failed to load service: %s\n" msg
]}
{b Error Conditions:}
- Service file does not exist
- Service file has incorrect permissions (not 0o600)
- Service file contains invalid TOML
- Service file does not contain proper TOML tables *)
val load_service : t -> service:string -> (Service.t, [> `Msg of string ]) result
(** [list_services t] returns all available service names.
Scans the keys directory for all [*.toml] files and returns their
base names (without the .toml extension). This allows applications
to discover what services have stored credentials.
@param t The Keyeio context
@return [Ok services] with a list of service names, or [Error (`Msg msg)] on failure
{b Example:}
{[
match Keyeio.list_services keyeio with
| Ok services ->
Printf.printf "Available services: %s\n"
(String.concat ", " services)
| Error (`Msg msg) ->
Printf.eprintf "Failed to list services: %s\n" msg
]}
{b Note:} Only files with [.toml] extension are considered. Files
with incorrect permissions are silently skipped. *)
val list_services : t -> (string list, [> `Msg of string ]) result
(** {1 Pretty Printing} *)
(** [pp ppf t] pretty prints the Keyeio configuration.
Shows the keys directory path and basic configuration information.
Does not display actual credentials for security.
@param ppf The formatter to print to
@param t The Keyeio context to print *)
val pp : Format.formatter -> t -> unit
(** {1 Cmdliner Integration} *)
module Cmd : sig
(** The type of the outer Keyeio context *)
type keyeio_t = t
(** Cmdliner integration for API key and credential management.
This module provides seamless integration with the Cmdliner library,
allowing applications to easily add credential loading to their
command-line interfaces. The integration follows the same patterns
as Xdge.Cmd for consistency.
{b Features:}
- Automatic command-line flag generation for profile selection
- Optional direct file path override
- Clear error messages for missing or invalid credentials
- Composable with other Cmdliner terms *)
(** [term ~app_name ~fs ~service ()] creates a Cmdliner term for loading credentials.
This function generates a Cmdliner term that handles credential loading
for a specific service. It automatically creates appropriate command-line
flags and handles loading the requested profile.
@param app_name The application name (used for XDG paths)
@param fs The Eio filesystem providing filesystem access
@param service The service name to load credentials for (e.g., "immiche")
@param profile Default profile name to use (default: "default")
@param key_file Add [--key-file] override flag (default: [true])
{b Generated Command-line Flags:}
- [--profile NAME]: Select which profile to use (default: "default")
- [--key-file PATH]: Override with direct TOML file path (if [key_file=true])
{b Flag Precedence:}
+ [--key-file PATH] - highest priority (if enabled)
+ [--profile NAME]
+ Default profile ("default")
{b Example - Basic usage:}
{[
open Cmdliner
let main profile =
let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
(* Use api_key *)
let () =
let key_term = Keyeio.Cmd.term
~app_name:"myapp"
~service:"immiche"
env#fs () in
let cmd = Cmd.v (Cmd.info "myapp")
Term.(const main $ key_term) in
exit (Cmd.eval cmd)
]}
{b Example - Custom profile default:}
{[
let key_term = Keyeio.Cmd.term
~app_name:"myapp"
~service:"immiche"
~profile:"production" (* Use production by default *)
env#fs () in
]}
{b Example - Without key-file override:}
{[
let key_term = Keyeio.Cmd.term
~app_name:"myapp"
~service:"immiche"
~key_file:false (* Only --profile flag, no --key-file *)
env#fs () in
]}
{b Error Handling:}
The term will fail with a clear error message if:
- The service file does not exist
- The requested profile is not found
- The TOML file is invalid
- File permissions are incorrect *)
val term :
app_name:string ->
fs:Eio.Fs.dir_ty Eio.Path.t ->
service:string ->
?profile:string ->
?key_file:bool ->
unit ->
Profile.t Cmdliner.Term.t
(** [create_term ~app_name ~fs ~service ~default_data ()] creates a Cmdliner term for creating keyfiles.
This function generates a Cmdliner term that handles interactive creation
of credential files. It prompts for required values, allows optional overrides
via command-line flags, and creates the TOML file with proper permissions.
@param app_name The application name (used for XDG paths)
@param fs The Eio filesystem providing filesystem access
@param service The service name to create credentials for (e.g., "karakeepe")
@param default_data Default key-value pairs with optional prompts
@param profile Default profile name to create (default: "default")
{b Generated Command-line Flags:}
- [--profile NAME]: Profile name to create (default: "default")
- One flag per key in default_data (e.g., [--api-key], [--base-url])
{b Example - Basic usage with prompts:}
{[
open Cmdliner
let create_cmd env =
let default_data = [
("api_key", None); (* Will prompt if not provided *)
("base_url", Some "https://hoard.recoil.org") (* Has default *)
] in
let create_term = Keyeio.Cmd.create_term
~app_name:"karakeepe"
~fs:env#fs
~service:"karakeepe"
~default_data
() in
Cmd.v (Cmd.info "init" ~doc:"Create karakeepe credentials")
create_term
]}
{b Behavior:}
- If a value is provided via CLI flag, use it
- If a value has a default in default_data, use it
- Otherwise, prompt interactively for the value
- Create the keyfile at [XDG_CONFIG_HOME/appname/keys/service.toml]
- Set file permissions to 0o600 for security
- Return 0 on success, 1 on failure *)
val create_term :
app_name:string ->
fs:Eio.Fs.dir_ty Eio.Path.t ->
service:string ->
default_data:(string * string option) list ->
?profile:string ->
unit ->
int Cmdliner.Term.t
(** [env_docs ~app_name ~service ()] generates documentation for environment variables.
Returns a formatted string documenting relevant environment variables
that affect key storage location. This is useful for generating man
pages or help text.
@param app_name The application name
@param service The service name
@return A formatted documentation string
{b Included Information:}
- How XDG_CONFIG_HOME affects key storage location
- Application-specific overrides
- File location examples
{b Example:}
{[
let env_section = env_docs ~app_name:"myapp" ~service:"immiche" ()
]} *)
val env_docs : app_name:string -> service:string -> unit -> string
end