My agentic slop goes here. Not intended for anyone else!
1(** Secure API key storage for Eio applications using XDG directories 2 3 This library provides secure storage and retrieval of API keys and credentials 4 for Eio-based applications. It integrates with the XDG Base Directory 5 Specification via the xdge library to store credentials in a consistent, 6 platform-appropriate location. 7 8 {b Key Features:} 9 10 - Store API keys in XDG-compliant directories with proper permissions 11 - Support multiple profiles per service (production, staging, development) 12 - TOML-based storage format for readability and flexibility 13 - Cmdliner integration for easy command-line usage 14 - Designed for future Secret Service API integration 15 16 {b Security Model:} 17 18 Currently, credentials are stored as TOML files in [XDG_CONFIG_HOME/appname/keys/] 19 with strict filesystem permissions (0o600 - owner read/write only). This follows 20 common practice for CLI tools and provides reasonable security for single-user 21 systems. 22 23 The design supports future integration with system keychains via the 24 freedesktop.org Secret Service API (GNOME Keyring, KWallet, KeePassXC) 25 without breaking the API. 26 27 {b Storage Structure:} 28 29 Keys are stored in [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] where SERVICE 30 is the name of the service (e.g., "immiche", "karakeepe"). Each service file 31 contains one or more named profiles: 32 33 {v 34 [default] 35 api_key = "abc123..." 36 base_url = "https://api.example.com" 37 38 [production] 39 api_key = "xyz789..." 40 base_url = "https://api.prod.example.com" 41 v} 42 43 {b Example Usage:} 44 45 {[ 46 open Cmdliner 47 48 let main (xdg, _) profile = 49 Eio_main.run @@ fun env -> 50 51 let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 52 let base_url = Keyeio.Profile.get_required profile ~key:"base_url" in 53 54 (* Use credentials to create API client *) 55 let client = Immiche.create ~sw ~env ~base_url ~api_key () in 56 (* ... *) 57 58 let () = 59 let xdg_term = Xdge.Cmd.term "myapp" env#fs () in 60 let key_term = Keyeio.Cmd.term 61 ~app_name:"myapp" 62 ~service:"immiche" 63 env#fs () in 64 65 let cmd = Cmd.v (Cmd.info "myapp") 66 Term.(const main $ xdg_term $ key_term) in 67 exit (Cmd.eval cmd) 68 ]} 69 70 @see <https://specifications.freedesktop.org/basedir-spec/latest/> XDG Base Directory Specification 71 @see <https://specifications.freedesktop.org/secret-service-spec/latest/> Secret Service API *) 72 73(** The main Keyeio context type containing directory paths and configuration. 74 75 A value of type [t] represents the keyeio configuration for a specific 76 application, including the keys directory path and storage backend. *) 77type t 78 79(** {1 Exceptions} *) 80 81(** Exception raised when a required key is not found in a profile. *) 82exception Key_not_found of string 83 84(** Exception raised when a profile is not found in a service. *) 85exception Profile_not_found of string 86 87(** Exception raised when attempting to access invalid TOML structure. *) 88exception Invalid_key_file of string 89 90(** {1 Profile} *) 91 92(** A profile represents a set of credentials for one service configuration. 93 94 Profiles allow you to store multiple sets of credentials for the same 95 service. For example, you might have "default", "production", and "staging" 96 profiles for an API service, each with different API keys and endpoints. 97 98 Each profile contains arbitrary key-value pairs stored as strings. Common 99 keys include "api_key", "base_url", "email", etc., but applications are 100 free to store any configuration needed. *) 101module Profile : sig 102 (** The type of a credential profile. *) 103 type t 104 105 (** [service t] returns the service name this profile belongs to. 106 107 @return The service name (e.g., "immiche", "karakeepe") *) 108 val service : t -> string 109 110 (** [name t] returns the profile name. 111 112 @return The profile name (e.g., "default", "production", "staging") *) 113 val name : t -> string 114 115 (** [get t ~key] retrieves a value from the profile. 116 117 @param t The profile to query 118 @param key The key to look up 119 @return [Some value] if the key exists, [None] otherwise 120 121 {b Example:} 122 {[ 123 match Profile.get profile ~key:"api_key" with 124 | Some key -> Printf.printf "API key: %s\n" key 125 | None -> Printf.printf "No API key found\n" 126 ]} *) 127 val get : t -> key:string -> string option 128 129 (** [get_required t ~key] retrieves a value that must exist. 130 131 @param t The profile to query 132 @param key The key to look up 133 @return The value associated with the key 134 @raise Key_not_found if the key does not exist 135 136 {b Example:} 137 {[ 138 let api_key = Profile.get_required profile ~key:"api_key" in 139 (* Use api_key, knowing it exists *) 140 ]} *) 141 val get_required : t -> key:string -> string 142 143 (** [keys t] returns all keys available in this profile. 144 145 @param t The profile to query 146 @return A list of all key names in the profile 147 148 {b Example:} 149 {[ 150 let available = Profile.keys profile in 151 List.iter (fun k -> Printf.printf "Available key: %s\n" k) available 152 ]} *) 153 val keys : t -> string list 154 155 (** [empty] creates an empty profile with no data. 156 157 This is useful when a command doesn't need API keys but must 158 conform to an interface that expects a profile parameter. 159 160 @return An empty profile with empty service name, empty profile name, and no data *) 161 val empty : t 162 163 (** [to_toml t] converts the profile to a TOML table representation. 164 165 Returns a TOML table containing all key-value pairs in the profile. 166 167 @param t The profile to convert 168 @return A TOML table representation *) 169 val to_toml : t -> Toml.Types.table 170 171 (** [pp ppf t] pretty prints a profile for debugging. 172 173 Displays the service name, profile name, and all key-value pairs. 174 Sensitive values (keys containing "key", "token", "password") are 175 masked for security. 176 177 @param ppf The formatter to print to 178 @param t The profile to print *) 179 val pp : Format.formatter -> t -> unit 180end 181 182(** {1 Service} *) 183 184(** A service represents all profiles for a given service. 185 186 Services group together multiple profiles for the same API or service. 187 For example, an "immiche" service might contain "default", "production", 188 and "staging" profiles, each with their own credentials. 189 190 Services are loaded from TOML files in the keys directory, with one file 191 per service: [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] *) 192module Service : sig 193 (** The type of a service containing multiple profiles. *) 194 type t 195 196 (** [name t] returns the service name. 197 198 @return The service name (e.g., "immiche", "karakeepe") *) 199 val name : t -> string 200 201 (** [profile_names t] returns all available profile names for this service. 202 203 @param t The service to query 204 @return A list of profile names (e.g., ["default"; "production"; "staging"]) 205 206 {b Example:} 207 {[ 208 let profiles = Service.profile_names service in 209 Printf.printf "Available profiles: %s\n" (String.concat ", " profiles) 210 ]} *) 211 val profile_names : t -> string list 212 213 (** [get_profile t name] retrieves a specific profile by name. 214 215 @param t The service to query 216 @param name The profile name to retrieve 217 @return [Some profile] if found, [None] otherwise 218 219 {b Example:} 220 {[ 221 match Service.get_profile service "production" with 222 | Some prof -> (* Use production profile *) 223 | None -> failwith "Production profile not configured" 224 ]} *) 225 val get_profile : t -> string -> Profile.t option 226 227 (** [default_profile t] retrieves the "default" profile if it exists. 228 229 This is a convenience function equivalent to [get_profile t "default"]. 230 231 @param t The service to query 232 @return [Some profile] if a "default" profile exists, [None] otherwise *) 233 val default_profile : t -> Profile.t option 234 235 (** [pp ppf t] pretty prints a service and all its profiles. 236 237 @param ppf The formatter to print to 238 @param t The service to print *) 239 val pp : Format.formatter -> t -> unit 240end 241 242(** {1 Construction} *) 243 244(** [create xdg] creates a Keyeio context from an Xdge context. 245 246 The keys are stored in a "keys" subdirectory of the XDG config directory. 247 For example, if the application is "myapp" and [XDG_CONFIG_HOME] is 248 [~/.config], keys will be stored in [~/.config/myapp/keys/]. 249 250 The keys directory is created with permissions 0o700 if it doesn't exist. 251 252 @param xdg The Xdge context providing XDG directory paths 253 @return A new Keyeio context 254 255 {b Example:} 256 {[ 257 let xdg = Xdge.create env#fs "myapp" in 258 let keyeio = Keyeio.create xdg in 259 (* Now you can load services and profiles *) 260 ]} *) 261val create : Xdge.t -> t 262 263(** {1 Creating Credentials} *) 264 265(** [create_default_keyfile t ~service ~profile ~data] creates a new credential file. 266 267 Creates a TOML file at [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] with 268 the provided profile and key-value pairs. If the file already exists, 269 it will be loaded and the new profile will be added or updated. 270 271 @param t The Keyeio context 272 @param service The service name (e.g., "karakeepe") 273 @param profile The profile name (default: "default") 274 @param data Key-value pairs to store in the profile 275 @return [Ok ()] on success, [Error (`Msg msg)] on failure 276 277 {b Example:} 278 {[ 279 let data = [ 280 ("api_key", "ak1_example_key"); 281 ("base_url", "https://api.example.com") 282 ] in 283 match Keyeio.create_default_keyfile keyeio ~service:"karakeepe" 284 ~profile:"default" ~data with 285 | Ok () -> Printf.printf "Key file created successfully\n" 286 | Error (`Msg msg) -> Printf.eprintf "Failed: %s\n" msg 287 ]} 288 289 {b Security:} The file is created with permissions 0o600 (owner read/write only). *) 290val create_default_keyfile : 291 t -> 292 service:string -> 293 profile:string -> 294 data:(string * string) list -> 295 (unit, [> `Msg of string ]) result 296 297(** {1 Loading Credentials} *) 298 299(** [load_service t ~service] loads all profiles for a given service. 300 301 Reads the TOML file [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] and 302 parses all profiles contained within. The file must contain TOML tables 303 where each section name is a profile name containing credential key-value pairs. 304 305 @param t The Keyeio context 306 @param service The service name to load (e.g., "immiche", "karakeepe") 307 @return [Ok service] on success, [Error (`Msg msg)] on failure 308 309 {b Example:} 310 {[ 311 match Keyeio.load_service keyeio ~service:"immiche" with 312 | Ok svc -> 313 begin match Service.default_profile svc with 314 | Some prof -> (* Use default profile *) 315 | None -> failwith "No default profile" 316 end 317 | Error (`Msg msg) -> 318 Printf.eprintf "Failed to load service: %s\n" msg 319 ]} 320 321 {b Error Conditions:} 322 - Service file does not exist 323 - Service file has incorrect permissions (not 0o600) 324 - Service file contains invalid TOML 325 - Service file does not contain proper TOML tables *) 326val load_service : t -> service:string -> (Service.t, [> `Msg of string ]) result 327 328(** [list_services t] returns all available service names. 329 330 Scans the keys directory for all [*.toml] files and returns their 331 base names (without the .toml extension). This allows applications 332 to discover what services have stored credentials. 333 334 @param t The Keyeio context 335 @return [Ok services] with a list of service names, or [Error (`Msg msg)] on failure 336 337 {b Example:} 338 {[ 339 match Keyeio.list_services keyeio with 340 | Ok services -> 341 Printf.printf "Available services: %s\n" 342 (String.concat ", " services) 343 | Error (`Msg msg) -> 344 Printf.eprintf "Failed to list services: %s\n" msg 345 ]} 346 347 {b Note:} Only files with [.toml] extension are considered. Files 348 with incorrect permissions are silently skipped. *) 349val list_services : t -> (string list, [> `Msg of string ]) result 350 351(** {1 Pretty Printing} *) 352 353(** [pp ppf t] pretty prints the Keyeio configuration. 354 355 Shows the keys directory path and basic configuration information. 356 Does not display actual credentials for security. 357 358 @param ppf The formatter to print to 359 @param t The Keyeio context to print *) 360val pp : Format.formatter -> t -> unit 361 362(** {1 Cmdliner Integration} *) 363 364module Cmd : sig 365 (** The type of the outer Keyeio context *) 366 type keyeio_t = t 367 368 (** Cmdliner integration for API key and credential management. 369 370 This module provides seamless integration with the Cmdliner library, 371 allowing applications to easily add credential loading to their 372 command-line interfaces. The integration follows the same patterns 373 as Xdge.Cmd for consistency. 374 375 {b Features:} 376 - Automatic command-line flag generation for profile selection 377 - Optional direct file path override 378 - Clear error messages for missing or invalid credentials 379 - Composable with other Cmdliner terms *) 380 381 (** [term ~app_name ~fs ~service ()] creates a Cmdliner term for loading credentials. 382 383 This function generates a Cmdliner term that handles credential loading 384 for a specific service. It automatically creates appropriate command-line 385 flags and handles loading the requested profile. 386 387 @param app_name The application name (used for XDG paths) 388 @param fs The Eio filesystem providing filesystem access 389 @param service The service name to load credentials for (e.g., "immiche") 390 @param profile Default profile name to use (default: "default") 391 @param key_file Add [--key-file] override flag (default: [true]) 392 393 {b Generated Command-line Flags:} 394 - [--profile NAME]: Select which profile to use (default: "default") 395 - [--key-file PATH]: Override with direct TOML file path (if [key_file=true]) 396 397 {b Flag Precedence:} 398 + [--key-file PATH] - highest priority (if enabled) 399 + [--profile NAME] 400 + Default profile ("default") 401 402 {b Example - Basic usage:} 403 {[ 404 open Cmdliner 405 406 let main profile = 407 let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 408 (* Use api_key *) 409 410 let () = 411 let key_term = Keyeio.Cmd.term 412 ~app_name:"myapp" 413 ~service:"immiche" 414 env#fs () in 415 416 let cmd = Cmd.v (Cmd.info "myapp") 417 Term.(const main $ key_term) in 418 exit (Cmd.eval cmd) 419 ]} 420 421 {b Example - Custom profile default:} 422 {[ 423 let key_term = Keyeio.Cmd.term 424 ~app_name:"myapp" 425 ~service:"immiche" 426 ~profile:"production" (* Use production by default *) 427 env#fs () in 428 ]} 429 430 {b Example - Without key-file override:} 431 {[ 432 let key_term = Keyeio.Cmd.term 433 ~app_name:"myapp" 434 ~service:"immiche" 435 ~key_file:false (* Only --profile flag, no --key-file *) 436 env#fs () in 437 ]} 438 439 {b Error Handling:} 440 441 The term will fail with a clear error message if: 442 - The service file does not exist 443 - The requested profile is not found 444 - The TOML file is invalid 445 - File permissions are incorrect *) 446 val term : 447 app_name:string -> 448 fs:Eio.Fs.dir_ty Eio.Path.t -> 449 service:string -> 450 ?profile:string -> 451 ?key_file:bool -> 452 unit -> 453 Profile.t Cmdliner.Term.t 454 455 (** [create_term ~app_name ~fs ~service ~default_data ()] creates a Cmdliner term for creating keyfiles. 456 457 This function generates a Cmdliner term that handles interactive creation 458 of credential files. It prompts for required values, allows optional overrides 459 via command-line flags, and creates the TOML file with proper permissions. 460 461 @param app_name The application name (used for XDG paths) 462 @param fs The Eio filesystem providing filesystem access 463 @param service The service name to create credentials for (e.g., "karakeepe") 464 @param default_data Default key-value pairs with optional prompts 465 @param profile Default profile name to create (default: "default") 466 467 {b Generated Command-line Flags:} 468 - [--profile NAME]: Profile name to create (default: "default") 469 - One flag per key in default_data (e.g., [--api-key], [--base-url]) 470 471 {b Example - Basic usage with prompts:} 472 {[ 473 open Cmdliner 474 475 let create_cmd env = 476 let default_data = [ 477 ("api_key", None); (* Will prompt if not provided *) 478 ("base_url", Some "https://hoard.recoil.org") (* Has default *) 479 ] in 480 481 let create_term = Keyeio.Cmd.create_term 482 ~app_name:"karakeepe" 483 ~fs:env#fs 484 ~service:"karakeepe" 485 ~default_data 486 () in 487 488 Cmd.v (Cmd.info "init" ~doc:"Create karakeepe credentials") 489 create_term 490 ]} 491 492 {b Behavior:} 493 - If a value is provided via CLI flag, use it 494 - If a value has a default in default_data, use it 495 - Otherwise, prompt interactively for the value 496 - Create the keyfile at [XDG_CONFIG_HOME/appname/keys/service.toml] 497 - Set file permissions to 0o600 for security 498 - Return 0 on success, 1 on failure *) 499 val create_term : 500 app_name:string -> 501 fs:Eio.Fs.dir_ty Eio.Path.t -> 502 service:string -> 503 default_data:(string * string option) list -> 504 ?profile:string -> 505 unit -> 506 int Cmdliner.Term.t 507 508 (** [env_docs ~app_name ~service ()] generates documentation for environment variables. 509 510 Returns a formatted string documenting relevant environment variables 511 that affect key storage location. This is useful for generating man 512 pages or help text. 513 514 @param app_name The application name 515 @param service The service name 516 @return A formatted documentation string 517 518 {b Included Information:} 519 - How XDG_CONFIG_HOME affects key storage location 520 - Application-specific overrides 521 - File location examples 522 523 {b Example:} 524 {[ 525 let env_section = env_docs ~app_name:"myapp" ~service:"immiche" () 526 ]} *) 527 val env_docs : app_name:string -> service:string -> unit -> string 528end