My agentic slop goes here. Not intended for anyone else!
1(** Secure API key storage for Eio applications using XDG directories *) 2 3(** {1 Exceptions} *) 4 5exception Key_not_found of string 6exception Profile_not_found of string 7exception Invalid_key_file of string 8 9(** {1 Profile Implementation} *) 10 11module Profile = struct 12 type t = { 13 service : string; 14 name : string; 15 data : (string * string) list; 16 } 17 18 let service t = t.service 19 let name t = t.name 20 21 let get t ~key = List.assoc_opt key t.data 22 23 let get_required t ~key = 24 match get t ~key with 25 | Some value -> value 26 | None -> 27 raise 28 (Key_not_found 29 (Printf.sprintf "Key '%s' not found in profile '%s' of service '%s'" key 30 t.name t.service)) 31 32 let keys t = List.map fst t.data 33 34 let empty = { service = ""; name = ""; data = [] } 35 36 let to_toml t = 37 let table = Toml.Types.Table.empty in 38 List.fold_left 39 (fun tbl (k, v) -> Toml.Types.Table.add (Toml.Types.Table.Key.of_string k) (Toml.Types.TString v) tbl) 40 table 41 t.data 42 43 let pp ppf t = 44 let mask_sensitive key = 45 let lower = String.lowercase_ascii key in 46 String.contains lower 'k' && String.contains lower 'e' && String.contains lower 'y' 47 || String.contains lower 't' && String.contains lower 'o' && String.contains lower 'k' 48 || String.contains lower 'p' 49 && String.contains lower 'a' 50 && String.contains lower 's' 51 && String.contains lower 's' 52 in 53 Fmt.pf ppf "@[<v 2>Profile %s.%s:@," t.service t.name; 54 List.iter 55 (fun (k, v) -> 56 if mask_sensitive k then 57 Fmt.pf ppf " %s: %s@," k (String.sub v 0 (min 8 (String.length v)) ^ "***") 58 else Fmt.pf ppf " %s: %s@," k v) 59 t.data; 60 Fmt.pf ppf "@]" 61end 62 63(** {1 Service Implementation} *) 64 65module Service = struct 66 type t = { 67 name : string; 68 profiles : (string * Profile.t) list; 69 } 70 71 let name t = t.name 72 let profile_names t = List.map fst t.profiles 73 let get_profile t name = List.assoc_opt name t.profiles 74 let default_profile t = get_profile t "default" 75 76 let pp ppf t = 77 Fmt.pf ppf "@[<v 2>Service %s:@," t.name; 78 List.iter 79 (fun (pname, profile) -> Fmt.pf ppf "@[<v 2>%s:@,%a@]@," pname Profile.pp profile) 80 t.profiles; 81 Fmt.pf ppf "@]" 82end 83 84(** {1 Main Context} *) 85 86type backend = Filesystem of { keys_dir : Eio.Fs.dir_ty Eio.Path.t } 87 88type t = { xdg : Xdge.t; backend : backend } 89 90let create xdg = 91 (* Keys are stored in a "keys" subdirectory of the config directory *) 92 let config_dir = Xdge.config_dir xdg in 93 let keys_dir = Eio.Path.(config_dir / "keys") in 94 95 (* Create keys directory with restrictive permissions *) 96 (try Eio.Path.mkdir ~perm:0o700 keys_dir with 97 | Eio.Io (Eio.Fs.E (Already_exists _), _) -> ()); 98 99 { xdg; backend = Filesystem { keys_dir } } 100 101(** {1 TOML Parsing Helpers} *) 102 103let parse_profile ~service ~profile_name table = 104 let data = 105 Toml.Types.Table.fold 106 (fun key value acc -> 107 match value with 108 | Toml.Types.TString s -> 109 let key_str = Toml.Types.Table.Key.to_string key in 110 (key_str, s) :: acc 111 | _ -> acc) 112 table 113 [] 114 in 115 { Profile.service; name = profile_name; data } 116 117let parse_service_file ~service toml_table = 118 let profiles = 119 Toml.Types.Table.fold 120 (fun key value acc -> 121 match value with 122 | Toml.Types.TTable profile_table -> 123 let profile_name = Toml.Types.Table.Key.to_string key in 124 (profile_name, parse_profile ~service ~profile_name profile_table) :: acc 125 | _ -> acc) 126 toml_table 127 [] 128 in 129 if profiles = [] then 130 raise 131 (Invalid_key_file (Printf.sprintf "Service file '%s.toml' contains no valid profile tables" service)) 132 else 133 { Service.name = service; profiles } 134 135(** {1 File Operations} *) 136 137let create_default_keyfile t ~service ~profile ~data = 138 match t.backend with 139 | Filesystem { keys_dir } -> 140 let service_file = Eio.Path.(keys_dir / (service ^ ".toml")) in 141 (try 142 (* Load existing service file if it exists, otherwise start fresh *) 143 let existing_profiles = 144 try 145 let content = Eio.Path.load service_file in 146 let toml = Toml.Parser.(from_string content |> unsafe) in 147 let svc = parse_service_file ~service toml in 148 svc.Service.profiles 149 with 150 | Eio.Io (Eio.Fs.E (Not_found _), _) -> [] 151 in 152 153 (* Create or update the profile *) 154 let new_profile = { Profile.service; name = profile; data } in 155 let updated_profiles = 156 (* Remove existing profile with same name if present *) 157 List.filter (fun (name, _) -> name <> profile) existing_profiles 158 @ [(profile, new_profile)] 159 in 160 161 (* Build TOML structure *) 162 let toml_table = Toml.Types.Table.empty in 163 let toml_table = 164 List.fold_left 165 (fun tbl (prof_name, prof) -> 166 let prof_table = Profile.to_toml prof in 167 Toml.Types.Table.add 168 (Toml.Types.Table.Key.of_string prof_name) 169 (Toml.Types.TTable prof_table) 170 tbl) 171 toml_table 172 updated_profiles 173 in 174 175 (* Convert to TOML string *) 176 let toml_str = Toml.Printer.string_of_table toml_table in 177 178 (* Write to file with restrictive permissions *) 179 Eio.Path.save ~create:(`Or_truncate 0o600) service_file toml_str; 180 181 Ok () 182 with 183 | Toml.Parser.Error (msg, _) -> 184 Error (`Msg (Printf.sprintf "Invalid TOML in existing %s.toml: %s" service msg)) 185 | exn -> 186 Error (`Msg (Printf.sprintf "Error creating key file: %s" (Printexc.to_string exn)))) 187 188let load_service t ~service = 189 match t.backend with 190 | Filesystem { keys_dir } -> 191 let service_file = Eio.Path.(keys_dir / (service ^ ".toml")) in 192 (try 193 (* Read and parse the TOML file *) 194 let content = Eio.Path.load service_file in 195 let toml = Toml.Parser.(from_string content |> unsafe) in 196 let service_data = parse_service_file ~service toml in 197 Ok service_data 198 with 199 | Eio.Io (Eio.Fs.E (Not_found _), _) -> 200 Error (`Msg (Printf.sprintf "Service file not found: %s.toml" service)) 201 | Toml.Parser.Error (msg, _) -> 202 Error (`Msg (Printf.sprintf "Invalid TOML in %s.toml: %s" service msg)) 203 | Invalid_key_file msg -> Error (`Msg msg) 204 | exn -> Error (`Msg (Printf.sprintf "Error loading service: %s" (Printexc.to_string exn)))) 205 206let list_services t = 207 match t.backend with 208 | Filesystem { keys_dir } -> 209 (try 210 let entries = Eio.Path.read_dir keys_dir in 211 let services = 212 List.filter_map 213 (fun entry -> 214 if String.ends_with ~suffix:".toml" entry then 215 Some (String.sub entry 0 (String.length entry - 5)) 216 else None) 217 entries 218 in 219 Ok (List.sort String.compare services) 220 with 221 | Eio.Io (Eio.Fs.E (Not_found _), _) -> 222 (* Keys directory doesn't exist yet *) 223 Ok [] 224 | exn -> Error (`Msg (Printf.sprintf "Error listing services: %s" (Printexc.to_string exn)))) 225 226let pp ppf t = 227 match t.backend with 228 | Filesystem { keys_dir } -> 229 Fmt.pf ppf "@[<v 2>Keyeio:@,"; 230 Fmt.pf ppf "Keys directory: %s@," (Eio.Path.native_exn keys_dir); 231 Fmt.pf ppf "Application: %s@," (Xdge.app_name t.xdg); 232 Fmt.pf ppf "@]" 233 234(** {1 Cmdliner Integration} *) 235 236module Cmd = struct 237 type keyeio_t = t 238 239 let term ~app_name ~fs ~service ?profile:(default_profile = "default") 240 ?(key_file = true) () = 241 let open Cmdliner in 242 (* Profile selection flag *) 243 let profile_flag = 244 let doc = Printf.sprintf "Profile name to use for %s service" service in 245 Arg.(value & opt string default_profile & info [ "profile" ] ~docv:"NAME" ~doc) 246 in 247 248 (* Optional key file override *) 249 let key_file_flag = 250 if key_file then 251 let doc = Printf.sprintf "Override with direct path to %s key file" service in 252 Some Arg.(value & opt (some file) None & info [ "key-file" ] ~docv:"FILE" ~doc) 253 else None 254 in 255 256 (* Term that loads the profile *) 257 let load_profile profile_name key_file_path = 258 (* If key_file path is provided, load from there *) 259 match key_file_path with 260 | Some path -> 261 (try 262 let content = In_channel.with_open_bin path In_channel.input_all in 263 let toml = Toml.Parser.(from_string content |> unsafe) in 264 match parse_service_file ~service toml with 265 | svc -> 266 (match Service.get_profile svc profile_name with 267 | Some prof -> prof 268 | None -> 269 failwith 270 (Printf.sprintf "Profile '%s' not found in %s" profile_name path)) 271 | exception exn -> 272 failwith 273 (Printf.sprintf "Error loading key file %s: %s" path 274 (Printexc.to_string exn)) 275 with 276 | Sys_error msg -> failwith (Printf.sprintf "Cannot read key file: %s" msg)) 277 | None -> 278 (* Load from XDG directory *) 279 let xdg = Xdge.create fs app_name in 280 let keyeio = create xdg in 281 (match load_service keyeio ~service with 282 | Ok svc -> 283 (match Service.get_profile svc profile_name with 284 | Some prof -> prof 285 | None -> 286 failwith 287 (Printf.sprintf "Profile '%s' not found in service '%s'" profile_name 288 service)) 289 | Error (`Msg msg) -> failwith msg) 290 in 291 292 (* Build the term *) 293 match key_file_flag with 294 | Some kf_flag -> Term.(const load_profile $ profile_flag $ kf_flag) 295 | None -> Term.(const load_profile $ profile_flag $ const None) 296 297 let create_term ~app_name ~fs ~service ~default_data 298 ?profile:(default_profile = "default") () = 299 let open Cmdliner in 300 301 (* Profile name flag *) 302 let profile_flag = 303 let doc = Printf.sprintf "Profile name to create for %s service" service in 304 Arg.(value & opt string default_profile & info [ "profile" ] ~docv:"NAME" ~doc) 305 in 306 307 (* Create flags for each key in default_data *) 308 let key_flags = 309 List.map 310 (fun (key, default_val) -> 311 let flag_name = String.map (fun c -> if c = '_' then '-' else c) key in 312 let doc = Printf.sprintf "Value for %s" key in 313 let term = 314 Arg.(value & opt (some string) default_val & info [ flag_name ] ~docv:(String.uppercase_ascii key) ~doc) 315 in 316 (key, term)) 317 default_data 318 in 319 320 (* Helper to prompt for a value if not provided *) 321 let prompt_for_value key = 322 Printf.printf "Enter %s: %!" key; 323 try 324 input_line stdin 325 with End_of_file -> 326 failwith (Printf.sprintf "Failed to read %s from stdin" key) 327 in 328 329 (* Term that creates the keyfile *) 330 let create_keyfile profile_name key_values = 331 try 332 (* Build the data list, prompting for missing values *) 333 let data = 334 List.map 335 (fun (key, value_opt) -> 336 match value_opt with 337 | Some v -> (key, v) 338 | None -> 339 let prompted = prompt_for_value key in 340 (key, prompted)) 341 (List.combine (List.map fst default_data) key_values) 342 in 343 344 (* Create the keyfile *) 345 let xdg = Xdge.create fs app_name in 346 let keyeio = create xdg in 347 match create_default_keyfile keyeio ~service ~profile:profile_name ~data with 348 | Ok () -> 349 let keys_dir = match keyeio.backend with 350 | Filesystem { keys_dir } -> Eio.Path.native_exn keys_dir 351 in 352 Printf.printf "Created %s profile in %s/%s.toml\n" profile_name keys_dir service; 353 0 354 | Error (`Msg msg) -> 355 Printf.eprintf "Failed to create key file: %s\n" msg; 356 1 357 with exn -> 358 Printf.eprintf "Error: %s\n" (Printexc.to_string exn); 359 1 360 in 361 362 (* Build the term by applying all key flags *) 363 let rec build_term acc_term = function 364 | [] -> Term.(const create_keyfile $ profile_flag $ acc_term) 365 | (_, flag_term) :: rest -> 366 build_term Term.(const (fun lst x -> lst @ [x]) $ acc_term $ flag_term) rest 367 in 368 build_term (Term.const []) key_flags 369 370 let env_docs ~app_name ~service () = 371 Printf.sprintf 372 {|ENVIRONMENT 373 Keys are stored in the XDG config directory under a 'keys' subdirectory. 374 The location is determined by the XDG Base Directory Specification: 375 376 XDG_CONFIG_HOME 377 Base directory for configuration files. If not set, defaults to 378 $HOME/.config. Keys for %s will be stored in: 379 $XDG_CONFIG_HOME/%s/keys/%s.toml 380 381 Example locations: 382 ~/.config/%s/keys/%s.toml (default) 383 /custom/config/%s/keys/%s.toml (if XDG_CONFIG_HOME=/custom/config) 384 385 File permissions should be 0600 (owner read/write only) for security. 386|} 387 app_name app_name service app_name service app_name service 388end