CCSDS Space Data Link Security (355.0-B-2)
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Eio wrappers for transport, SDLS, CFDP, and LTP

- transport-eio: CLTU/ASM over Eio flows, COP-1 service layer
- sdls-eio: file-backed keystore, SA store, OTAR, security log,
KEK management, encrypted KV store, SDLS server
- cfdp: Eio filesystem-backed filestore for segmented transfers
- ltp-eio: length-prefixed segment send/recv over TCP

Ported from borealis eio/ wrappers with dependency updates
(crypto instead of mirage-crypto, bare int for Spi/Vcid/Scid).

+1478
+16
dune-project
··· 18 18 (bitv (>= 1.0)) 19 19 (wire (>= 0.9)) 20 20 (alcotest :with-test))) 21 + 22 + (package 23 + (name sdls-eio) 24 + (synopsis "Eio-based SDLS persistence (SA, keystore, OTAR)") 25 + (depends 26 + (ocaml (>= 5.1)) 27 + (sdls (= :version)) 28 + (eio (>= 1.0)) 29 + (eio_main :with-test) 30 + (fpath (>= 0.7)) 31 + (crypto (>= 0.1)) 32 + (crypto-rng (>= 0.1)) 33 + (pbkdf2 (>= 0.1)) 34 + (hkdf (>= 0.1)) 35 + (fmt (>= 0.9)) 36 + (logs (>= 0.7))))
+4
eio/dune
··· 1 + (library 2 + (name sdls_eio) 3 + (public_name sdls-eio) 4 + (libraries sdls eio eio.unix fpath crypto crypto-rng fmt logs pbkdf2 hkdf))
+175
eio/kek.ml
··· 1 + (** Master Key (KEK) management for CLI tools. *) 2 + 3 + let ( / ) = Eio.Path.( / ) 4 + 5 + (* {1 Constants} *) 6 + 7 + let kek_len = 32 (* AES-256 *) 8 + let salt_len = 16 (* PBKDF2 salt *) 9 + let nonce_len = 12 (* GCM nonce *) 10 + let tag_len = 16 (* GCM tag *) 11 + 12 + (* PBKDF2 iterations - OWASP 2023 recommendation for SHA-256. 13 + Note: Argon2id would be preferable for memory-hardness against GPU attacks, 14 + but mirage-crypto doesn't include it. If Argon2 becomes available in deps, 15 + consider migrating (would require format versioning for backwards compat). *) 16 + let pbkdf2_count = 600_000 17 + 18 + (* {1 Types} *) 19 + 20 + type error = 21 + | Not_found 22 + | Permission_denied 23 + | Insecure_permissions of int 24 + | Corrupted 25 + | Wrong_password 26 + | Io_error of string 27 + 28 + let pp_error ppf = function 29 + | Not_found -> Fmt.pf ppf "key file not found" 30 + | Permission_denied -> Fmt.pf ppf "permission denied" 31 + | Insecure_permissions mode -> 32 + Fmt.pf ppf "insecure permissions: %03o (expected 600 or 400)" mode 33 + | Corrupted -> Fmt.pf ppf "key file corrupted" 34 + | Wrong_password -> Fmt.pf ppf "wrong password" 35 + | Io_error msg -> Fmt.pf ppf "I/O error: %s" msg 36 + 37 + (* {1 Permission Checking} *) 38 + 39 + let get_mode path = 40 + try 41 + let stat = Eio.Path.stat ~follow:true path in 42 + Some stat.perm 43 + with _ -> None 44 + 45 + let check_permissions path = 46 + match get_mode path with 47 + | None -> Error Not_found 48 + | Some mode -> 49 + let masked = mode land 0o777 in 50 + (* Mode 600/400 restricts access to owner only. 51 + If we can read it, we're the owner. *) 52 + if masked <> 0o600 && masked <> 0o400 then 53 + Error (Insecure_permissions masked) 54 + else Ok () 55 + 56 + (* {1 Password-based Encryption} *) 57 + 58 + (* Derive key from password using PBKDF2-SHA256 (RFC 8018). *) 59 + let derive_key ~password ~salt = 60 + Bytes.of_string 61 + (Pbkdf2.derive ~password ~salt:(Bytes.to_string salt) 62 + ~iterations:pbkdf2_count ~length:kek_len) 63 + 64 + module C = Crypto.AES 65 + 66 + let encrypt_kek ~password kek = 67 + let salt = Bytes.of_string (Crypto_rng.generate salt_len) in 68 + let derived = derive_key ~password ~salt in 69 + let nonce = Crypto_rng.generate nonce_len in 70 + let key = C.GCM.of_secret (Bytes.to_string derived) in 71 + let ciphertext = 72 + C.GCM.authenticate_encrypt ~key ~nonce ~adata:"sdls-kek" 73 + (Bytes.to_string kek) 74 + in 75 + (* salt || nonce || ciphertext+tag *) 76 + let result = Bytes.create (salt_len + nonce_len + String.length ciphertext) in 77 + Bytes.blit salt 0 result 0 salt_len; 78 + Bytes.blit_string nonce 0 result salt_len nonce_len; 79 + Bytes.blit_string ciphertext 0 result (salt_len + nonce_len) 80 + (String.length ciphertext); 81 + result 82 + 83 + let decrypt_kek ~password data = 84 + let data_len = Bytes.length data in 85 + if data_len < salt_len + nonce_len + kek_len + tag_len then Error Corrupted 86 + else 87 + let salt = Bytes.sub data 0 salt_len in 88 + let nonce = Bytes.sub_string data salt_len nonce_len in 89 + let ciphertext = 90 + Bytes.sub_string data (salt_len + nonce_len) 91 + (data_len - salt_len - nonce_len) 92 + in 93 + let derived = derive_key ~password ~salt in 94 + let key = C.GCM.of_secret (Bytes.to_string derived) in 95 + match 96 + C.GCM.authenticate_decrypt ~key ~nonce ~adata:"sdls-kek" ciphertext 97 + with 98 + | None -> Error Wrong_password 99 + | Some plaintext -> 100 + if String.length plaintext <> kek_len then Error Corrupted 101 + else Ok (Bytes.of_string plaintext) 102 + 103 + (* {1 File Operations} *) 104 + 105 + let ensure_dir path = 106 + match Eio.Path.kind ~follow:true path with 107 + | `Directory -> () 108 + | `Not_found -> Eio.Path.mkdir ~perm:0o700 path 109 + | _ -> failwith (Fmt.str "%a: not a directory" Eio.Path.pp path) 110 + | exception Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> 111 + Eio.Path.mkdir ~perm:0o700 path 112 + 113 + let load ~file = 114 + match check_permissions file with 115 + | Error e -> Error e 116 + | Ok () -> ( 117 + try 118 + let data = Bytes.of_string (Eio.Path.load file) in 119 + if Bytes.length data = kek_len then Ok data (* Unencrypted *) 120 + else if Bytes.length data >= salt_len + nonce_len + kek_len + tag_len 121 + then Error Corrupted (* Encrypted but no password provided *) 122 + else Error Corrupted 123 + with 124 + | Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> Error Not_found 125 + | Eio.Io (Eio.Fs.E (Eio.Fs.Permission_denied _), _) -> 126 + Error Permission_denied 127 + | exn -> Error (Io_error (Printexc.to_string exn))) 128 + 129 + let load_with_password ~password ~file = 130 + match check_permissions file with 131 + | Error e -> Error e 132 + | Ok () -> ( 133 + try 134 + let data = Bytes.of_string (Eio.Path.load file) in 135 + if Bytes.length data = kek_len then 136 + Ok data (* Unencrypted, password ignored *) 137 + else decrypt_kek ~password data 138 + with 139 + | Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> Error Not_found 140 + | Eio.Io (Eio.Fs.E (Eio.Fs.Permission_denied _), _) -> 141 + Error Permission_denied 142 + | exn -> Error (Io_error (Printexc.to_string exn))) 143 + 144 + let create ?password ~file () = 145 + (* Check if file already exists *) 146 + (match Eio.Path.kind ~follow:true file with 147 + | `Not_found -> () 148 + | _ -> failwith "key file already exists" 149 + | exception Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> ()); 150 + (* Generate random KEK *) 151 + let kek = Bytes.of_string (Crypto_rng.generate kek_len) in 152 + (* Optionally encrypt with password *) 153 + let data = 154 + match password with None -> kek | Some pw -> encrypt_kek ~password:pw kek 155 + in 156 + (* Save with secure permissions *) 157 + try 158 + Eio.Path.save ~create:(`Exclusive 0o600) file (Bytes.to_string data); 159 + Ok kek 160 + with 161 + | Eio.Io (Eio.Fs.E (Eio.Fs.Permission_denied _), _) -> Error Permission_denied 162 + | exn -> Error (Io_error (Printexc.to_string exn)) 163 + 164 + let load_or_create ?password ~dir () = 165 + ensure_dir dir; 166 + let file = dir / "master.key" in 167 + match Eio.Path.kind ~follow:true file with 168 + | `Regular_file -> ( 169 + match password with 170 + | None -> load ~file 171 + | Some pw -> load_with_password ~password:pw ~file) 172 + | `Not_found -> create ?password ~file () 173 + | _ -> Error (Io_error "master.key exists but is not a regular file") 174 + | exception Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> 175 + create ?password ~file ()
+72
eio/kek.mli
··· 1 + (** Master Key (KEK) management for CLI tools. 2 + 3 + Provides secure KEK storage following the "generate-once, protect-with- 4 + permissions" pattern used by tools like age, sops, and pass. 5 + 6 + {2 Security Model} 7 + 8 + - KEK is 32 random bytes (AES-256) 9 + - File permissions enforced (mode 600 or 400) 10 + - Warns/fails if permissions are too open 11 + - Optional password protection using PBKDF2-SHA256 (RFC 8018) 12 + 13 + {2 Usage} 14 + 15 + {[ 16 + (* Simple: auto-generate on first use *) 17 + let kek = Kek.load_or_create ~dir () in 18 + 19 + (* With password protection *) 20 + let kek = Kek.load_or_create ~password:"secret" ~dir () in 21 + 22 + (* Explicit key file *) 23 + let kek = Kek.load ~file:(Eio.Path.(dir / "master.key")) in 24 + ]} 25 + 26 + {b Note}: For production spacecraft systems, use proper key management (HSM, 27 + TPM, or mission-specific procedures). This module is for development and 28 + ground station tooling. *) 29 + 30 + type error = 31 + | Not_found 32 + | Permission_denied 33 + | Insecure_permissions of int (** Actual mode *) 34 + | Corrupted 35 + | Wrong_password 36 + | Io_error of string 37 + 38 + val pp_error : error Fmt.t 39 + 40 + val load : file:_ Eio.Path.t -> (bytes, error) result 41 + (** [load ~file] reads a KEK from [file]. 42 + 43 + Fails if: 44 + - File doesn't exist 45 + - Permissions are not 600 (or 400) 46 + - File content is invalid *) 47 + 48 + val load_or_create : 49 + ?password:string -> dir:_ Eio.Path.t -> unit -> (bytes, error) result 50 + (** [load_or_create ?password ~dir ()] loads or generates a KEK. 51 + 52 + If [dir/master.key] exists, loads it. Otherwise generates 32 random bytes 53 + and saves with mode 600. 54 + 55 + @param password 56 + If provided, KEK is encrypted with password using PBKDF2-SHA256 (RFC 8018) 57 + for key derivation and AES-256-GCM for encryption. File format: 58 + [salt(16) || nonce(12) || ciphertext+tag(48)]. Without password, file is 59 + just the raw 32 bytes. 60 + 61 + @param dir Directory for key storage (created with mode 700 if needed) *) 62 + 63 + val create : 64 + ?password:string -> file:_ Eio.Path.t -> unit -> (bytes, error) result 65 + (** [create ?password ~file ()] generates a new KEK and saves it. 66 + 67 + Fails if file already exists (to prevent accidental overwrite). *) 68 + 69 + val check_permissions : _ Eio.Path.t -> (unit, error) result 70 + (** [check_permissions file] verifies file has secure permissions. 71 + 72 + Returns [Ok ()] if mode is 600 or 400. *)
+186
eio/keystore_eio.ml
··· 1 + (** Eio filesystem-backed Keystore with encryption at rest. 2 + 3 + Per CCSDS 355.1-B-1: Keys are protected using a master key (KEK) with 4 + AES-256-GCM authenticated encryption. This follows the same two-tier key 5 + hierarchy used for OTAR: 6 + - Master Key (KEK): protects session keys at rest 7 + - Session Keys: protected by master key, used for traffic 8 + 9 + File format: IV(12) || Ciphertext(1 + key_len) || MAC(16) Plaintext: 10 + state_byte(1) || key_material(key_len) *) 11 + 12 + module Binary = Sdls.Binary 13 + module Keyid = Sdls.Keyid 14 + module Keystore = Sdls.Keystore 15 + 16 + let ( / ) = Eio.Path.( / ) 17 + 18 + (* {1 Constants} *) 19 + 20 + let iv_len = 12 (* GCM IV length *) 21 + let mac_len = 16 (* GCM tag length *) 22 + 23 + (* {1 Helpers} *) 24 + 25 + let ensure_dir path = 26 + match Eio.Path.kind ~follow:true path with 27 + | `Directory -> () 28 + | `Not_found -> Eio.Path.mkdir ~perm:0o755 path 29 + | _ -> failwith (Fmt.str "%a: not a directory" Eio.Path.pp path) 30 + | exception Eio.Io (Eio.Fs.E (Not_found _), _) -> 31 + Eio.Path.mkdir ~perm:0o755 path 32 + 33 + (* {1 Encryption at Rest} 34 + 35 + Keys are encrypted with AES-256-GCM using a master key (KEK). 36 + Additional Authenticated Data (AAD) includes the key ID to prevent 37 + key file swapping attacks. *) 38 + 39 + module C = Crypto.AES 40 + 41 + let encrypt_entry ~kek ~key_id (entry : Keystore.entry) : string = 42 + (* Generate random IV *) 43 + let iv = Crypto_rng.generate iv_len in 44 + (* Plaintext: state || key_material *) 45 + let w = Binary.Writer.create 64 in 46 + Keystore.write_entry w entry; 47 + let plaintext = Bytes.to_string (Binary.Writer.contents w) in 48 + (* AAD: key_id prevents swapping key files *) 49 + let aad = Bytes.make 2 '\x00' in 50 + Bytes.set_uint16_be aad 0 (Keyid.to_int key_id); 51 + (* Encrypt with AES-256-GCM *) 52 + let key = C.GCM.of_secret (Bytes.to_string kek) in 53 + let result = 54 + C.GCM.authenticate_encrypt ~key ~nonce:iv ~adata:(Bytes.to_string aad) 55 + plaintext 56 + in 57 + (* Output: IV || ciphertext || tag *) 58 + iv ^ result 59 + 60 + let decrypt_entry ~kek ~key_id (data : string) : Keystore.entry option = 61 + let data_len = String.length data in 62 + (* Minimum: IV(12) + state(1) + MAC(16) = 29 bytes *) 63 + if data_len < iv_len + 1 + mac_len then None 64 + else 65 + let iv = String.sub data 0 iv_len in 66 + let ciphertext = String.sub data iv_len (data_len - iv_len) in 67 + (* AAD: key_id for swapping protection *) 68 + let aad = Bytes.make 2 '\x00' in 69 + Bytes.set_uint16_be aad 0 (Keyid.to_int key_id); 70 + (* Decrypt with AES-256-GCM *) 71 + let key = C.GCM.of_secret (Bytes.to_string kek) in 72 + match 73 + C.GCM.authenticate_decrypt ~key ~nonce:iv ~adata:(Bytes.to_string aad) 74 + ciphertext 75 + with 76 + | None -> None (* MAC verification failed *) 77 + | Some plaintext -> 78 + Keystore.read_entry (Binary.Reader.of_bytes (Bytes.of_string plaintext)) 79 + 80 + (* {1 Plaintext Format (for unencrypted mode)} 81 + 82 + Key files stored as: state_byte || key_material 83 + Used when no KEK is provided (testing only - NOT RECOMMENDED). *) 84 + 85 + let encode_entry_plain (entry : Keystore.entry) : string = 86 + let w = Binary.Writer.create 64 in 87 + Keystore.write_entry w entry; 88 + Bytes.to_string (Binary.Writer.contents w) 89 + 90 + let decode_entry_plain (data : string) : Keystore.entry option = 91 + Keystore.read_entry (Binary.Reader.of_bytes (Bytes.of_string data)) 92 + 93 + (* {1 Constructors} *) 94 + 95 + (** [of_path ~kek root] creates a keystore at [root] with encryption at rest. *) 96 + let of_path ~kek (root : _ Eio.Path.t) : Keystore.t = 97 + if Bytes.length kek <> 32 then 98 + invalid_arg "KEK must be 32 bytes for AES-256-GCM"; 99 + ensure_dir root; 100 + ensure_dir (root / "keys"); 101 + let keys_dir = root / "keys" in 102 + let module Backend = struct 103 + type t = unit 104 + 105 + let get () key_id = 106 + let path = keys_dir / Fmt.str "%a.key" Keyid.pp key_id in 107 + try 108 + let data = Eio.Path.load path in 109 + decrypt_entry ~kek ~key_id data 110 + with Eio.Io (Eio.Fs.E (Not_found _), _) -> None 111 + 112 + let set () key_id entry = 113 + let path = keys_dir / Fmt.str "%a.key" Keyid.pp key_id in 114 + Eio.Path.save ~create:(`Or_truncate 0o600) path 115 + (encrypt_entry ~kek ~key_id entry) 116 + 117 + let remove () key_id = 118 + let path = keys_dir / Fmt.str "%a.key" Keyid.pp key_id in 119 + try Eio.Path.unlink path with Eio.Io (Eio.Fs.E (Not_found _), _) -> () 120 + 121 + let list () = 122 + try 123 + Eio.Path.read_dir keys_dir 124 + |> List.filter_map (fun name -> 125 + if String.ends_with ~suffix:".key" name then 126 + let base = String.sub name 0 (String.length name - 4) in 127 + match int_of_string_opt base with 128 + | Some n -> Keyid.of_int n 129 + | None -> None 130 + else None) 131 + with Eio.Io (Eio.Fs.E (Not_found _), _) -> [] 132 + 133 + let list_by_state () state = 134 + list () 135 + |> List.filter (fun key_id -> 136 + match get () key_id with 137 + | Some entry -> entry.state = state 138 + | None -> false) 139 + end in 140 + let module M = Keystore.Make (Backend) in 141 + M.v () 142 + 143 + (** [of_path_unencrypted root] creates a keystore WITHOUT encryption at rest. *) 144 + let of_path_unencrypted (root : _ Eio.Path.t) : Keystore.t = 145 + ensure_dir root; 146 + ensure_dir (root / "keys"); 147 + let keys_dir = root / "keys" in 148 + let module Backend = struct 149 + type t = unit 150 + 151 + let get () key_id = 152 + let path = keys_dir / Fmt.str "%a.key" Keyid.pp key_id in 153 + try 154 + let data = Eio.Path.load path in 155 + decode_entry_plain data 156 + with Eio.Io (Eio.Fs.E (Not_found _), _) -> None 157 + 158 + let set () key_id entry = 159 + let path = keys_dir / Fmt.str "%a.key" Keyid.pp key_id in 160 + Eio.Path.save ~create:(`Or_truncate 0o600) path (encode_entry_plain entry) 161 + 162 + let remove () key_id = 163 + let path = keys_dir / Fmt.str "%a.key" Keyid.pp key_id in 164 + try Eio.Path.unlink path with Eio.Io (Eio.Fs.E (Not_found _), _) -> () 165 + 166 + let list () = 167 + try 168 + Eio.Path.read_dir keys_dir 169 + |> List.filter_map (fun name -> 170 + if String.ends_with ~suffix:".key" name then 171 + let base = String.sub name 0 (String.length name - 4) in 172 + match int_of_string_opt base with 173 + | Some n -> Keyid.of_int n 174 + | None -> None 175 + else None) 176 + with Eio.Io (Eio.Fs.E (Not_found _), _) -> [] 177 + 178 + let list_by_state () state = 179 + list () 180 + |> List.filter (fun key_id -> 181 + match get () key_id with 182 + | Some entry -> entry.state = state 183 + | None -> false) 184 + end in 185 + let module M = Keystore.Make (Backend) in 186 + M.v ()
+39
eio/keystore_eio.mli
··· 1 + (** Eio filesystem-backed Keystore with encryption at rest. 2 + 3 + Per CCSDS 355.1-B-1: Session keys are protected using a master key (KEK) 4 + with AES-256-GCM authenticated encryption. 5 + 6 + {2 Directory Layout} 7 + 8 + {v 9 + <root>/ 10 + keys/ 11 + <key_id>.key # IV(12) || AES-GCM(state || key) || MAC(16) 12 + v} 13 + 14 + {2 Security Properties} 15 + 16 + - Keys encrypted at rest with AES-256-GCM 17 + - Fresh IV generated for each write (requires RNG initialization) 18 + - AAD includes key_id to prevent file swapping attacks 19 + - Master key must be 32 bytes (AES-256) *) 20 + 21 + val of_path : kek:bytes -> _ Eio.Path.t -> Sdls.Keystore.t 22 + (** [of_path ~kek dir] creates a keystore with encryption at rest. 23 + 24 + @param kek 25 + Master key (KEK) for encrypting stored keys. Must be exactly 32 bytes for 26 + AES-256-GCM. 27 + 28 + Creates subdirectory [keys/] if it doesn't exist. 29 + 30 + @raise Invalid_argument if [kek] is not 32 bytes. *) 31 + 32 + val of_path_unencrypted : _ Eio.Path.t -> Sdls.Keystore.t 33 + (** [of_path_unencrypted dir] creates a keystore WITHOUT encryption. 34 + 35 + {b WARNING}: Keys are stored in plaintext. Use only for: 36 + - Testing 37 + - Environments with filesystem-level encryption 38 + 39 + For production, use {!of_path} with a proper master key. *)
+159
eio/kv.ml
··· 1 + (** Generic file-backed key-value store with optional encryption. 2 + 3 + Uses HKDF (RFC 5869) to derive store-specific keys from the master KEK, 4 + providing cryptographic separation between different stores. *) 5 + 6 + (* {1 Constants} *) 7 + 8 + let iv_len = 12 9 + let mac_len = 16 10 + let key_len = 32 (* AES-256 *) 11 + 12 + (* {1 Key Derivation} *) 13 + 14 + (* Derive store-specific key from master KEK using HKDF (RFC 5869). 15 + The info parameter provides domain separation between stores. *) 16 + let derive_store_key ~kek ~info = 17 + Hkdf.derive ~hash:`SHA256 ~salt:Bytes.empty ~ikm:kek 18 + ~info:(Bytes.of_string info) ~len:key_len 19 + 20 + (* {1 Encryption} *) 21 + 22 + module C = Crypto.AES 23 + 24 + let encrypt ~key ~aad data = 25 + let iv = Crypto_rng.generate iv_len in 26 + let aes_key = C.GCM.of_secret (Bytes.to_string key) in 27 + let ciphertext = 28 + C.GCM.authenticate_encrypt ~key:aes_key ~nonce:iv ~adata:aad 29 + (Bytes.to_string data) 30 + in 31 + Bytes.of_string (iv ^ ciphertext) 32 + 33 + let decrypt ~key ~aad data = 34 + let data_len = Bytes.length data in 35 + if data_len < iv_len + mac_len then None 36 + else 37 + let iv = Bytes.sub_string data 0 iv_len in 38 + let ciphertext = Bytes.sub_string data iv_len (data_len - iv_len) in 39 + let aes_key = C.GCM.of_secret (Bytes.to_string key) in 40 + match 41 + C.GCM.authenticate_decrypt ~key:aes_key ~nonce:iv ~adata:aad ciphertext 42 + with 43 + | None -> None 44 + | Some plaintext -> Some (Bytes.of_string plaintext) 45 + 46 + (* {1 Type} 47 + 48 + We use a record of closures to capture the path and mode at construction 49 + time. This avoids GADT complications while keeping the interface simple. *) 50 + 51 + type t = { 52 + get : string -> bytes option; 53 + set : string -> bytes -> unit; 54 + remove : string -> unit; 55 + list : unit -> string list; 56 + exists : string -> bool; 57 + } 58 + 59 + (* {1 Helpers} *) 60 + 61 + (* Create directory with 0700 to protect entry names (metadata). 62 + Entry names appear as filenames; 0755 would leak this information. *) 63 + let ensure_dir path = 64 + let open Eio.Path in 65 + match kind ~follow:true path with 66 + | `Directory -> () 67 + | `Not_found -> mkdir ~perm:0o700 path 68 + | _ -> failwith (Fmt.str "%a: not a directory" pp path) 69 + | exception Eio.Io (Eio.Fs.E (Not_found _), _) -> mkdir ~perm:0o700 path 70 + 71 + (* {1 Constructors} *) 72 + 73 + let of_path ~kek ~dir ~ext = 74 + if Bytes.length kek <> 32 then 75 + invalid_arg "KEK must be 32 bytes for AES-256-GCM"; 76 + ensure_dir dir; 77 + (* Derive store-specific key using HKDF with dir path as context *) 78 + let _, dir_name = dir in 79 + let key = derive_store_key ~kek ~info:("sdls-kv:" ^ dir_name ^ ext) in 80 + let open Eio.Path in 81 + { 82 + get = 83 + (fun entry -> 84 + let path = dir / (entry ^ ext) in 85 + try 86 + let data = Bytes.of_string (load path) in 87 + decrypt ~key ~aad:entry data 88 + with Eio.Io (Eio.Fs.E (Not_found _), _) -> None); 89 + set = 90 + (fun entry value -> 91 + let path = dir / (entry ^ ext) in 92 + let encrypted = encrypt ~key ~aad:entry value in 93 + save ~create:(`Or_truncate 0o600) path (Bytes.to_string encrypted)); 94 + remove = 95 + (fun entry -> 96 + let path = dir / (entry ^ ext) in 97 + try unlink path with Eio.Io (Eio.Fs.E (Not_found _), _) -> ()); 98 + list = 99 + (fun () -> 100 + try 101 + read_dir dir 102 + |> List.filter_map (fun name -> 103 + if String.ends_with ~suffix:ext name then 104 + Some 105 + (String.sub name 0 (String.length name - String.length ext)) 106 + else None) 107 + with Eio.Io (Eio.Fs.E (Not_found _), _) -> []); 108 + exists = 109 + (fun entry -> 110 + let path = dir / (entry ^ ext) in 111 + match kind ~follow:true path with 112 + | `Regular_file -> true 113 + | _ -> false 114 + | exception Eio.Io (Eio.Fs.E (Not_found _), _) -> false); 115 + } 116 + 117 + let of_path_unencrypted ~dir ~ext = 118 + ensure_dir dir; 119 + let open Eio.Path in 120 + { 121 + get = 122 + (fun key -> 123 + let path = dir / (key ^ ext) in 124 + try Some (Bytes.of_string (load path)) 125 + with Eio.Io (Eio.Fs.E (Not_found _), _) -> None); 126 + set = 127 + (fun key value -> 128 + let path = dir / (key ^ ext) in 129 + save ~create:(`Or_truncate 0o600) path (Bytes.to_string value)); 130 + remove = 131 + (fun key -> 132 + let path = dir / (key ^ ext) in 133 + try unlink path with Eio.Io (Eio.Fs.E (Not_found _), _) -> ()); 134 + list = 135 + (fun () -> 136 + try 137 + read_dir dir 138 + |> List.filter_map (fun name -> 139 + if String.ends_with ~suffix:ext name then 140 + Some 141 + (String.sub name 0 (String.length name - String.length ext)) 142 + else None) 143 + with Eio.Io (Eio.Fs.E (Not_found _), _) -> []); 144 + exists = 145 + (fun key -> 146 + let path = dir / (key ^ ext) in 147 + match kind ~follow:true path with 148 + | `Regular_file -> true 149 + | _ -> false 150 + | exception Eio.Io (Eio.Fs.E (Not_found _), _) -> false); 151 + } 152 + 153 + (* {1 Operations} *) 154 + 155 + let get t key = t.get key 156 + let set t key value = t.set key value 157 + let remove t key = t.remove key 158 + let list t = t.list () 159 + let exists t key = t.exists key
+55
eio/kv.mli
··· 1 + (** Generic file-backed key-value store with optional encryption. 2 + 3 + A simple KV abstraction over the filesystem where each key maps to a file. 4 + Supports optional AES-256-GCM encryption at rest. 5 + 6 + {2 File Format} 7 + 8 + - Encrypted: [IV(12) || AES-GCM(value) || MAC(16)] 9 + - Plaintext: [value] 10 + 11 + {2 Security} 12 + 13 + When using encryption: 14 + - Store-specific keys derived from master KEK using HKDF (RFC 5869) 15 + - Different stores get cryptographically independent keys 16 + - Fresh IV generated for each write 17 + - AAD includes entry name to prevent file swapping attacks 18 + - KEK must be 32 bytes (AES-256) *) 19 + 20 + type t 21 + (** A file-backed KV store. *) 22 + 23 + val of_path : kek:bytes -> dir:_ Eio.Path.t -> ext:string -> t 24 + (** [of_path ~kek ~dir ~ext] creates an encrypted KV store. 25 + 26 + Files are stored as [dir/<key><ext>] with AES-256-GCM encryption. AAD 27 + includes the key name to prevent file swapping attacks. 28 + 29 + @param kek Master key for encryption (must be 32 bytes) 30 + @param dir Directory to store files in (created if needed) 31 + @param ext File extension (e.g., ".dat", ".key") 32 + @raise Invalid_argument if [kek] is not 32 bytes *) 33 + 34 + val of_path_unencrypted : dir:_ Eio.Path.t -> ext:string -> t 35 + (** [of_path_unencrypted ~dir ~ext] creates an unencrypted KV store. 36 + 37 + {b WARNING}: Data stored in plaintext. Use only for testing or when 38 + filesystem provides its own encryption. *) 39 + 40 + (** {1 Operations} *) 41 + 42 + val get : t -> string -> bytes option 43 + (** [get t key] retrieves the value for [key], or [None] if not found. *) 44 + 45 + val set : t -> string -> bytes -> unit 46 + (** [set t key value] stores [value] under [key]. *) 47 + 48 + val remove : t -> string -> unit 49 + (** [remove t key] deletes the entry for [key]. *) 50 + 51 + val list : t -> string list 52 + (** [list t] returns all keys in the store. *) 53 + 54 + val exists : t -> string -> bool 55 + (** [exists t key] returns [true] if [key] exists. *)
+108
eio/otar_eio.ml
··· 1 + (** Eio filesystem-backed OTAR with optional encryption. 2 + 3 + Master keys now have lifecycle state tracking per CCSDS 355.1-B-1, using the 4 + same Key.t type as session keys. Both are serialized with state information. 5 + *) 6 + 7 + module Binary = Sdls.Binary 8 + module Key = Sdls.Key 9 + module Otar = Sdls.Otar 10 + 11 + (* {1 Helpers} *) 12 + 13 + (** Serialize a Key.t to bytes for storage. *) 14 + let serialize_key key = 15 + let w = Binary.Writer.create 64 in 16 + Key.write w key; 17 + Binary.Writer.contents w 18 + 19 + (** Deserialize a Key.t from bytes. *) 20 + let deserialize_key ~kid data = Key.read ~kid (Binary.Reader.of_bytes data) 21 + 22 + (* {1 Constructors} *) 23 + 24 + let of_path ~kek (root : _ Eio.Path.t) : Otar.t = 25 + if Bytes.length kek <> 32 then 26 + invalid_arg "KEK must be 32 bytes for AES-256-GCM"; 27 + Schema.init root; 28 + let master_kv = 29 + Kv.of_path ~kek ~dir:(Schema.otar_master_keys_dir root) ~ext:".key" 30 + in 31 + let session_kv = 32 + Kv.of_path ~kek ~dir:(Schema.otar_session_keys_dir root) ~ext:".key" 33 + in 34 + let module Backend = struct 35 + type t = unit 36 + 37 + (* Master key operations - now with lifecycle state tracking *) 38 + let get_master_key () mkid = 39 + match Kv.get master_kv (string_of_int mkid) with 40 + | None -> None 41 + | Some data -> deserialize_key ~kid:mkid data 42 + 43 + let set_master_key () mkid key = 44 + Kv.set master_kv (string_of_int mkid) (serialize_key key) 45 + 46 + let remove_master_key () mkid = Kv.remove master_kv (string_of_int mkid) 47 + 48 + let list_master_keys () = 49 + Kv.list master_kv |> List.filter_map int_of_string_opt 50 + 51 + (* Session key operations *) 52 + let get_session_key () kid = 53 + match Kv.get session_kv (string_of_int kid) with 54 + | None -> None 55 + | Some data -> deserialize_key ~kid data 56 + 57 + let set_session_key () kid key = 58 + Kv.set session_kv (string_of_int kid) (serialize_key key) 59 + 60 + let remove_session_key () kid = Kv.remove session_kv (string_of_int kid) 61 + 62 + let list_session_keys () = 63 + Kv.list session_kv |> List.filter_map int_of_string_opt 64 + end in 65 + let module M = Otar.Make (Backend) in 66 + M.v () 67 + 68 + let of_path_unencrypted (root : _ Eio.Path.t) : Otar.t = 69 + Schema.init root; 70 + let master_kv = 71 + Kv.of_path_unencrypted ~dir:(Schema.otar_master_keys_dir root) ~ext:".key" 72 + in 73 + let session_kv = 74 + Kv.of_path_unencrypted ~dir:(Schema.otar_session_keys_dir root) ~ext:".key" 75 + in 76 + let module Backend = struct 77 + type t = unit 78 + 79 + (* Master key operations - now with lifecycle state tracking *) 80 + let get_master_key () mkid = 81 + match Kv.get master_kv (string_of_int mkid) with 82 + | None -> None 83 + | Some data -> deserialize_key ~kid:mkid data 84 + 85 + let set_master_key () mkid key = 86 + Kv.set master_kv (string_of_int mkid) (serialize_key key) 87 + 88 + let remove_master_key () mkid = Kv.remove master_kv (string_of_int mkid) 89 + 90 + let list_master_keys () = 91 + Kv.list master_kv |> List.filter_map int_of_string_opt 92 + 93 + (* Session key operations *) 94 + let get_session_key () kid = 95 + match Kv.get session_kv (string_of_int kid) with 96 + | None -> None 97 + | Some data -> deserialize_key ~kid data 98 + 99 + let set_session_key () kid key = 100 + Kv.set session_kv (string_of_int kid) (serialize_key key) 101 + 102 + let remove_session_key () kid = Kv.remove session_kv (string_of_int kid) 103 + 104 + let list_session_keys () = 105 + Kv.list session_kv |> List.filter_map int_of_string_opt 106 + end in 107 + let module M = Otar.Make (Backend) in 108 + M.v ()
+40
eio/otar_eio.mli
··· 1 + (** Eio filesystem-backed OTAR with optional encryption. 2 + 3 + Provides persistent storage for OTAR master keys (KEKs) and session keys 4 + with their lifecycle states. Uses AES-256-GCM encryption when a KEK is 5 + provided. 6 + 7 + {2 Directory Layout} 8 + 9 + {v 10 + <root>/ 11 + otar/ 12 + master_keys/ 13 + <mkid>.key # Encrypted master key with state 14 + session_keys/ 15 + <kid>.key # Encrypted session key with state 16 + v} 17 + 18 + {2 Example} 19 + 20 + {[ 21 + let root = Eio.Path.(fs / "state") in 22 + let otar = Otar_eio.of_path ~kek root in 23 + Sdls.Otar.add_master_key otar ~mkid:1 master_material; 24 + assert (Sdls.Otar.activate_master_key otar 1); 25 + match Sdls.Otar.receive otar cmd with 26 + | Ok reply -> send reply 27 + | Error e -> log_error e 28 + ]} *) 29 + 30 + val of_path : kek:bytes -> _ Eio.Path.t -> Sdls.Otar.t 31 + (** [of_path ~kek root] creates a persistent OTAR store. 32 + 33 + @param kek 32-byte key encryption key for AES-256-GCM 34 + @param root Root directory for storage (created if needed) 35 + @raise Invalid_argument if kek is not 32 bytes *) 36 + 37 + val of_path_unencrypted : _ Eio.Path.t -> Sdls.Otar.t 38 + (** [of_path_unencrypted root] creates an unencrypted persistent store. 39 + 40 + {b Warning}: Keys are stored in plaintext. Use only for testing. *)
+87
eio/sa_eio.ml
··· 1 + (** Eio filesystem-backed SA store with optional encryption. *) 2 + 3 + module Binary = Sdls.Binary 4 + module Sa = Sdls.Sa 5 + 6 + (* {1 Constructors} *) 7 + 8 + let of_path ~kek (root : _ Eio.Path.t) : Sa.t = 9 + if Bytes.length kek <> 32 then 10 + invalid_arg "KEK must be 32 bytes for AES-256-GCM"; 11 + Schema.init root; 12 + let config_kv = Kv.of_path ~kek ~dir:(Schema.sa_dir root) ~ext:".config" in 13 + let dyn_kv = Kv.of_path ~kek ~dir:(Schema.sa_dir root) ~ext:".dyn" in 14 + let module Backend = struct 15 + type t = unit 16 + 17 + let get_config () spi = 18 + match Kv.get config_kv (string_of_int spi) with 19 + | None -> None 20 + | Some data -> Sa.read_config (Binary.Reader.of_bytes data) 21 + 22 + let get_dyn () spi = 23 + match Kv.get dyn_kv (string_of_int spi) with 24 + | None -> None 25 + | Some data -> Sa.read_dyn (Binary.Reader.of_bytes data) 26 + 27 + let set_config () spi config = 28 + let w = Binary.Writer.create 128 in 29 + Sa.write_config w config; 30 + Kv.set config_kv (string_of_int spi) (Binary.Writer.contents w) 31 + 32 + let set_dyn () spi dyn = 33 + (* Size: header ~40 bytes + replay_window (arsnw/8 bytes, up to 8KB) *) 34 + let size = 64 + ((Bitv.length dyn.Sa.replay_window + 7) / 8) in 35 + let w = Binary.Writer.create size in 36 + Sa.write_dyn w dyn; 37 + Kv.set dyn_kv (string_of_int spi) (Binary.Writer.contents w) 38 + 39 + let remove () spi = 40 + let key = string_of_int spi in 41 + Kv.remove config_kv key; 42 + Kv.remove dyn_kv key 43 + 44 + let list () = Kv.list config_kv |> List.filter_map int_of_string_opt 45 + end in 46 + let module M = Sa.Make (Backend) in 47 + M.v () 48 + 49 + let of_path_unencrypted (root : _ Eio.Path.t) : Sa.t = 50 + Schema.init root; 51 + let config_kv = 52 + Kv.of_path_unencrypted ~dir:(Schema.sa_dir root) ~ext:".config" 53 + in 54 + let dyn_kv = Kv.of_path_unencrypted ~dir:(Schema.sa_dir root) ~ext:".dyn" in 55 + let module Backend = struct 56 + type t = unit 57 + 58 + let get_config () spi = 59 + match Kv.get config_kv (string_of_int spi) with 60 + | None -> None 61 + | Some data -> Sa.read_config (Binary.Reader.of_bytes data) 62 + 63 + let get_dyn () spi = 64 + match Kv.get dyn_kv (string_of_int spi) with 65 + | None -> None 66 + | Some data -> Sa.read_dyn (Binary.Reader.of_bytes data) 67 + 68 + let set_config () spi config = 69 + let w = Binary.Writer.create 128 in 70 + Sa.write_config w config; 71 + Kv.set config_kv (string_of_int spi) (Binary.Writer.contents w) 72 + 73 + let set_dyn () spi dyn = 74 + let size = 64 + ((Bitv.length dyn.Sa.replay_window + 7) / 8) in 75 + let w = Binary.Writer.create size in 76 + Sa.write_dyn w dyn; 77 + Kv.set dyn_kv (string_of_int spi) (Binary.Writer.contents w) 78 + 79 + let remove () spi = 80 + let key = string_of_int spi in 81 + Kv.remove config_kv key; 82 + Kv.remove dyn_kv key 83 + 84 + let list () = Kv.list config_kv |> List.filter_map int_of_string_opt 85 + end in 86 + let module M = Sa.Make (Backend) in 87 + M.v ()
+31
eio/sa_eio.mli
··· 1 + (** Eio filesystem-backed SA store with optional encryption. 2 + 3 + Persistent implementation of {!Sdls.Sa.BACKEND} that stores SA config and 4 + dynamic state to files. 5 + 6 + {2 Directory Layout} 7 + 8 + {v 9 + <root>/sa/ 10 + +-- <spi>.config # Static SA configuration (encrypted) 11 + +-- <spi>.dyn # Dynamic state: IV, ARSN, replay window (encrypted) 12 + v} 13 + 14 + {2 Security} 15 + 16 + - SA config and dynamic state encrypted at rest with AES-256-GCM 17 + - Fresh IV generated for each write 18 + - Master key must be 32 bytes *) 19 + 20 + val of_path : kek:bytes -> _ Eio.Path.t -> Sdls.Sa.t 21 + (** [of_path ~kek root] creates an SA store with encryption at rest. 22 + 23 + Creates [root/sa/] subdirectory if it doesn't exist. 24 + 25 + @param kek Master key for encryption (must be 32 bytes) 26 + @raise Invalid_argument if [kek] is not 32 bytes *) 27 + 28 + val of_path_unencrypted : _ Eio.Path.t -> Sdls.Sa.t 29 + (** [of_path_unencrypted root] creates an SA store WITHOUT encryption. 30 + 31 + {b WARNING}: SA state stored in plaintext. Use only for testing. *)
+34
eio/schema.ml
··· 1 + (** Filesystem schema for SDLS Eio backends. *) 2 + 3 + let ( / ) = Eio.Path.( / ) 4 + 5 + (* {1 Path Construction} *) 6 + 7 + let keys_dir root = root / "keys" 8 + let key_file root kid = keys_dir root / Fmt.str "%a.key" Sdls.Keyid.pp kid 9 + let sa_dir root = root / "sa" 10 + let sa_config_file root spi = sa_dir root / Fmt.str "%d.config" spi 11 + let sa_dyn_file root spi = sa_dir root / Fmt.str "%d.dyn" spi 12 + let otar_dir root = root / "otar" 13 + let otar_master_keys_dir root = otar_dir root / "master_keys" 14 + let otar_session_keys_dir root = otar_dir root / "session_keys" 15 + let security_log_file root = root / "security.log" 16 + let alarm_file root = root / "alarm" 17 + 18 + (* {1 Directory Setup} *) 19 + 20 + let ensure_dir path = 21 + match Eio.Path.kind ~follow:true path with 22 + | `Directory -> () 23 + | `Not_found -> Eio.Path.mkdir ~perm:0o755 path 24 + | _ -> failwith (Fmt.str "%a: not a directory" Eio.Path.pp path) 25 + | exception Eio.Io (Eio.Fs.E (Not_found _), _) -> 26 + Eio.Path.mkdir ~perm:0o755 path 27 + 28 + let init root = 29 + ensure_dir root; 30 + ensure_dir (keys_dir root); 31 + ensure_dir (sa_dir root); 32 + ensure_dir (otar_dir root); 33 + ensure_dir (otar_master_keys_dir root); 34 + ensure_dir (otar_session_keys_dir root)
+66
eio/schema.mli
··· 1 + (** Filesystem schema for SDLS Eio backends. 2 + 3 + Defines the canonical directory layout for persistent storage: 4 + 5 + {v 6 + <data>/ 7 + +-- keys/ 8 + | +-- <key_id>.key # Encrypted key material + state 9 + +-- sa/ 10 + | +-- <spi>.config # Static SA configuration 11 + | +-- <spi>.dyn # Dynamic SA state (IV, ARSN, replay) 12 + +-- otar/ 13 + | +-- master_keys/ 14 + | | +-- <mkid>.key # Master keys for OTAR 15 + | +-- session_keys/ 16 + | +-- <kid>.key # Session keys with lifecycle state 17 + +-- security.log # Security event log 18 + +-- alarm # Persistent alarm flag 19 + v} 20 + 21 + All paths are relative to a user-provided data directory. 22 + 23 + @see <https://public.ccsds.org/Pubs/355x0b2.pdf> CCSDS 355.0-B-2 Table 6-1 24 + *) 25 + 26 + (** {1 Path Construction} *) 27 + 28 + val keys_dir : 'a Eio.Path.t -> 'a Eio.Path.t 29 + (** [keys_dir root] returns [root/keys/]. *) 30 + 31 + val key_file : 'a Eio.Path.t -> Sdls.Keyid.t -> 'a Eio.Path.t 32 + (** [key_file root kid] returns [root/keys/<kid>.key]. *) 33 + 34 + val sa_dir : 'a Eio.Path.t -> 'a Eio.Path.t 35 + (** [sa_dir root] returns [root/sa/]. *) 36 + 37 + val sa_config_file : 'a Eio.Path.t -> int -> 'a Eio.Path.t 38 + (** [sa_config_file root spi] returns [root/sa/<spi>.config]. *) 39 + 40 + val sa_dyn_file : 'a Eio.Path.t -> int -> 'a Eio.Path.t 41 + (** [sa_dyn_file root spi] returns [root/sa/<spi>.dyn]. *) 42 + 43 + val otar_dir : 'a Eio.Path.t -> 'a Eio.Path.t 44 + (** [otar_dir root] returns [root/otar/]. *) 45 + 46 + val otar_master_keys_dir : 'a Eio.Path.t -> 'a Eio.Path.t 47 + (** [otar_master_keys_dir root] returns [root/otar/master_keys/]. *) 48 + 49 + val otar_session_keys_dir : 'a Eio.Path.t -> 'a Eio.Path.t 50 + (** [otar_session_keys_dir root] returns [root/otar/session_keys/]. *) 51 + 52 + val security_log_file : 'a Eio.Path.t -> 'a Eio.Path.t 53 + (** [security_log_file root] returns [root/security.log]. *) 54 + 55 + val alarm_file : 'a Eio.Path.t -> 'a Eio.Path.t 56 + (** [alarm_file root] returns [root/alarm]. *) 57 + 58 + (** {1 Directory Setup} *) 59 + 60 + val ensure_dir : _ Eio.Path.t -> unit 61 + (** [ensure_dir path] creates [path] if it doesn't exist. 62 + @raise Failure if path exists but is not a directory. *) 63 + 64 + val init : _ Eio.Path.t -> unit 65 + (** [init root] creates the full directory structure under [root]. Creates 66 + [keys/] and [sa/] subdirectories. *)
+117
eio/security_eio.ml
··· 1 + (** Eio file-backed Security Log and Alarm Flag. 2 + 3 + Persistent implementation of Security store that writes events to a file and 4 + maintains persistent alarm flag per CCSDS 355.1-B-1 section 4.2.2.4.7. 5 + 6 + Events are stored in TLV format (timestamp + event_data) for durability. *) 7 + 8 + module Binary = Sdls.Binary 9 + module Security = Sdls.Security 10 + 11 + let ( / ) = Eio.Path.( / ) 12 + 13 + (* {1 File Format} 14 + 15 + The log file contains concatenated TLV-encoded events. 16 + Each event: 8-byte timestamp + tag (1) + length (2) + data (variable). 17 + 18 + The alarm file contains a single byte: 0x00 = false, 0x01 = true. 19 + 20 + On startup, we read all events from the file and alarm state. 21 + On append, we append to the file and update in-memory cache. 22 + On erase, we truncate the file. *) 23 + 24 + (* Use GADT to hide path capability type *) 25 + type backend = 26 + | Backend : { 27 + log_path : 'a Eio.Path.t; 28 + alarm_path : 'a Eio.Path.t; 29 + mutable events : Security.event list; 30 + mutable alarm : bool; 31 + max_events : int; 32 + } 33 + -> backend 34 + 35 + let load_events path = 36 + try 37 + let content = Eio.Path.load path in 38 + let buf = Bytes.of_string content in 39 + let r = Binary.Reader.of_bytes buf in 40 + let events = ref [] in 41 + while Binary.Reader.remaining r >= 11 do 42 + (* min event: 8 timestamp + 1 tag + 2 len + 0 data *) 43 + match Security.read_event r with 44 + | Ok event -> events := event :: !events 45 + | Error _ -> () (* skip malformed events *) 46 + done; 47 + List.rev !events 48 + with 49 + | Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> [] 50 + | _ -> [] 51 + 52 + let save_event path event = 53 + let w = Binary.Writer.create 64 in 54 + Security.write_event w event; 55 + let data = Binary.Writer.contents w |> Bytes.to_string in 56 + (* Append to file *) 57 + try 58 + let existing = Eio.Path.load path in 59 + Eio.Path.save ~create:(`Or_truncate 0o600) path (existing ^ data) 60 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> 61 + Eio.Path.save ~create:(`Or_truncate 0o600) path data 62 + 63 + let load_alarm path = 64 + try 65 + let content = Eio.Path.load path in 66 + String.length content > 0 && String.get content 0 = '\x01' 67 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> false 68 + 69 + let save_alarm path alarm = 70 + let byte = if alarm then "\x01" else "\x00" in 71 + Eio.Path.save ~create:(`Or_truncate 0o600) path byte 72 + 73 + (* {1 Backend Implementation} *) 74 + 75 + module Eio_backend : Security.S with type t = backend = struct 76 + type t = backend 77 + 78 + let get_alarm (Backend t) = t.alarm 79 + 80 + let set_alarm (Backend t) v = 81 + if t.alarm <> v then begin 82 + t.alarm <- v; 83 + save_alarm t.alarm_path v 84 + end 85 + 86 + let append (Backend t) event = 87 + if List.length t.events < t.max_events then begin 88 + t.events <- t.events @ [ event ]; 89 + save_event t.log_path event 90 + end 91 + 92 + let count (Backend t) = List.length t.events 93 + 94 + let capacity (Backend t) = 95 + (* Estimate ~32 bytes per event on average (timestamp 8 + TLV event data ~24). 96 + Actual usage varies: Auth_failure ~28, Iv_warning ~12, etc. *) 97 + (t.max_events - List.length t.events) * 32 98 + 99 + let dump (Backend t) = t.events 100 + 101 + let erase (Backend t) = 102 + t.events <- []; 103 + (* Truncate file *) 104 + try Eio.Path.save ~create:(`Or_truncate 0o600) t.log_path "" with _ -> () 105 + end 106 + 107 + (* {1 Constructor} *) 108 + 109 + module M = Security.Make (Eio_backend) 110 + 111 + let of_path ?(max_events = 1000) path = 112 + let log_path = path / "security.log" in 113 + let alarm_path = path / "alarm" in 114 + let events = load_events log_path in 115 + let alarm = load_alarm alarm_path in 116 + let backend = Backend { log_path; alarm_path; events; alarm; max_events } in 117 + M.v backend
+31
eio/security_eio.mli
··· 1 + (** Eio file-backed Security Log and Alarm Flag. 2 + 3 + Persistent implementation of security log and alarm flag per CCSDS 4 + 355.1-B-1. 5 + 6 + {2 Persistence} 7 + 8 + - Events stored in [dir/security.log] (TLV format) 9 + - Alarm flag stored in [dir/alarm] (single byte) 10 + - Both survive restarts 11 + 12 + {2 Usage} 13 + 14 + {[ 15 + let store = Security_eio.of_path (fs / "data") in 16 + Security.auth_failure store ~timestamp:12345L ~spi:1 ~reason:Bad_mac (); 17 + Security.set_alarm store; 18 + let events = Security.dump store in 19 + ... 20 + ]} *) 21 + 22 + val of_path : ?max_events:int -> _ Eio.Path.t -> Sdls.Security.t 23 + (** [of_path ?max_events dir] creates a security store at [dir]. 24 + 25 + Files created: 26 + - [dir/security.log] - Event log 27 + - [dir/alarm] - Persistent alarm flag 28 + 29 + Existing data is loaded from files on startup. 30 + 31 + @param max_events Maximum events to store (default 1000). *)
+140
eio/server.ml
··· 1 + (** SDLS server - stateful wrapper around pure Sdls module. 2 + 3 + Manages: 4 + - Security Association state 5 + - Key storage 6 + - Frame processing 7 + - Error rate limiting (to prevent log floods) 8 + - Automatic SA persistence via Sa.t *) 9 + 10 + module Sa = Sdls.Sa 11 + module Keystore = Sdls.Keystore 12 + module Binary = Sdls.Binary 13 + 14 + let src = Logs.Src.create "sdls.server" ~doc:"SDLS server" 15 + 16 + module Log = (val Logs.src_log src : Logs.LOG) 17 + 18 + (** {1 Error Rate Limiting} 19 + 20 + Rate-limited error logging to prevent log floods under attack or noisy 21 + channel conditions. Uses a simple token bucket: logs up to [burst] errors 22 + immediately, then suppresses until [interval_ns] passes. *) 23 + 24 + type rate_limiter = { 25 + mutable last_log_ns : int64; 26 + mutable suppressed : int; 27 + interval_ns : int64; (** Minimum time between log bursts *) 28 + burst : int; (** Errors to log before suppressing *) 29 + mutable burst_count : int; 30 + } 31 + 32 + let make_rate_limiter ?(interval_sec = 1.0) ?(burst = 5) () = 33 + { 34 + last_log_ns = 0L; 35 + suppressed = 0; 36 + interval_ns = Int64.of_float (interval_sec *. 1e9); 37 + burst; 38 + burst_count = 0; 39 + } 40 + 41 + (** [log_err_limited rl clock msg] logs error [msg] if rate limit allows. *) 42 + let log_err_limited rl clock msg = 43 + let now = Eio.Time.now clock |> ( *. ) 1e9 |> Int64.of_float in 44 + let elapsed = Int64.sub now rl.last_log_ns in 45 + if elapsed >= rl.interval_ns then begin 46 + (* Reset burst window *) 47 + if rl.suppressed > 0 then 48 + Log.warn (fun m -> 49 + m "Suppressed %d errors in last interval" rl.suppressed); 50 + rl.last_log_ns <- now; 51 + rl.suppressed <- 0; 52 + rl.burst_count <- 1; 53 + Log.err (fun m -> m "%s" msg) 54 + end 55 + else if rl.burst_count < rl.burst then begin 56 + rl.burst_count <- rl.burst_count + 1; 57 + Log.err (fun m -> m "%s" msg) 58 + end 59 + else rl.suppressed <- rl.suppressed + 1 60 + 61 + (** {1 Configuration and State} *) 62 + 63 + type config = { spi : int; sa_db : Sa.t option; keystore : Keystore.t } 64 + (** Server configuration *) 65 + 66 + type t = { 67 + spi : int; [@warning "-69"] 68 + mutable sa : Sa.entry; 69 + sa_db : Sa.t option; 70 + keystore : Keystore.t; 71 + error_limiter : rate_limiter; 72 + } 73 + (** Server state *) 74 + 75 + let create (config : config) = 76 + (* Load initial SA from database if provided *) 77 + let sa = 78 + match config.sa_db with 79 + | Some db -> ( 80 + match Sa.get db config.spi with 81 + | Some sa -> sa 82 + | None -> Fmt.failwith "SA not found in database: SPI %d" config.spi) 83 + | None -> 84 + Fmt.failwith 85 + "sa_db required: either provide sa_db or use Sa.in_memory()" 86 + in 87 + { 88 + spi = config.spi; 89 + sa; 90 + sa_db = config.sa_db; 91 + keystore = config.keystore; 92 + error_limiter = make_rate_limiter (); 93 + } 94 + 95 + (** Persist full SA dyn state to database if configured. *) 96 + let persist_sa t sa' = 97 + match t.sa_db with 98 + | None -> () 99 + | Some db -> Sa.set_dyn db sa'.Sa.config.spi sa'.Sa.dyn 100 + 101 + (* {1 Generic Frame Operations} *) 102 + 103 + (** Protect a frame using the generic frame-level API. 104 + 105 + [protect t clock ~frame_hdr_bytes ~plaintext w] encrypts and authenticates 106 + the given frame data. Updates internal SA state and persists to database. *) 107 + let protect t clock ~frame_hdr_bytes ~plaintext w = 108 + match 109 + Sdls.protect_frame ~sa:t.sa ~keys:t.keystore 110 + ~get_ek:Keystore.get_encryption_key ~get_ak:Keystore.get_auth_key 111 + ~frame_hdr_bytes ~plaintext w 112 + with 113 + | Error e -> 114 + log_err_limited t.error_limiter clock 115 + (Format.asprintf "Protect failed: %a" Sdls.pp_error e); 116 + Error e 117 + | Ok sa' -> 118 + persist_sa t sa'; 119 + t.sa <- sa'; 120 + Ok (Binary.Writer.contents w) 121 + 122 + (** Unprotect a frame using the generic frame-level API. 123 + 124 + [unprotect t clock ~frame_hdr_len ~frame_hdr_bytes ~total_len r] decrypts 125 + and verifies the given protected frame. Updates internal SA state and 126 + persists to database. *) 127 + let unprotect t clock ~frame_hdr_len ~frame_hdr_bytes ~total_len r = 128 + match 129 + Sdls.unprotect_frame ~sa:t.sa ~keys:t.keystore 130 + ~get_ek:Keystore.get_decryption_key ~get_ak:Keystore.get_verify_key 131 + ~frame_hdr_len ~frame_hdr_bytes ~total_len r 132 + with 133 + | Error e -> 134 + log_err_limited t.error_limiter clock 135 + (Format.asprintf "Unprotect failed: %a" Sdls.pp_error e); 136 + Error e 137 + | Ok (plaintext, sa') -> 138 + persist_sa t sa'; 139 + t.sa <- sa'; 140 + Ok plaintext
+73
eio/server.mli
··· 1 + (** SDLS server - stateful wrapper around pure {!Sdls} module. 2 + 3 + Manages: 4 + - Security Association state (IV, ARSN, replay window) 5 + - Key storage 6 + - Error rate limiting (prevents log floods under attack/noise) 7 + - Automatic SA persistence via {!Sa.t} 8 + 9 + {2 Persistence} 10 + 11 + For production use, provide [sa_db] in the config. The full SA dynamic state 12 + (IV, ARSN, replay window) is persisted after each successful operation. This 13 + is critical for AEAD security: losing IV state causes catastrophic nonce 14 + reuse for GCM/CCM modes. 15 + 16 + {2 Rate limiting} 17 + 18 + All operations rate-limit error logging to prevent log floods under noisy 19 + channel conditions or attack. Default: 5 errors per second burst, then 20 + suppressed with periodic "Suppressed N errors" warnings. *) 21 + 22 + (** {1 Configuration} *) 23 + 24 + type config = { 25 + spi : int; (** SPI identifying the SA to use *) 26 + sa_db : Sdls.Sa.t option; 27 + (** SA store for persistence. If [None], SA state is in-memory only (not 28 + recommended for production). *) 29 + keystore : Sdls.Keystore.t; 30 + (** Keystore for encryption/authentication keys. *) 31 + } 32 + (** Server configuration. *) 33 + 34 + (** {1 Server State} *) 35 + 36 + type t 37 + (** Opaque server state. Contains mutable SA state, keys, and rate limiter. *) 38 + 39 + val create : config -> t 40 + (** Create a new server instance from configuration. *) 41 + 42 + (** {1 Generic Frame Operations} 43 + 44 + These functions process frames using the generic frame-level API from 45 + {!Sdls}. The SA state is updated internally after each successful operation. 46 + *) 47 + 48 + val protect : 49 + t -> 50 + _ Eio.Time.clock -> 51 + frame_hdr_bytes:bytes -> 52 + plaintext:bytes -> 53 + Sdls.Binary.Writer.t -> 54 + (bytes, Sdls.error) result 55 + (** [protect t clock ~frame_hdr_bytes ~plaintext w] encrypts and authenticates a 56 + frame. 57 + 58 + Updates internal SA state (IV, ARSN) on success. Errors are logged with rate 59 + limiting. *) 60 + 61 + val unprotect : 62 + t -> 63 + _ Eio.Time.clock -> 64 + frame_hdr_len:int -> 65 + frame_hdr_bytes:bytes -> 66 + total_len:int -> 67 + Sdls.Binary.Reader.t -> 68 + (bytes, Sdls.error) result 69 + (** [unprotect t clock ~frame_hdr_len ~frame_hdr_bytes ~total_len r] decrypts 70 + and verifies a protected frame. 71 + 72 + Updates internal SA state (replay window) on success. Errors are logged with 73 + rate limiting. *)
+3
lib/sdls.ml
··· 570 570 571 571 (* {1 Re-exports} *) 572 572 573 + module Binary = Binary 573 574 module Crypto = Sdls_crypto 574 575 module Cmac = Cmac 575 576 module Hmac = Hmac ··· 577 578 module Key = Key 578 579 module Keyid = Keyid 579 580 module Keystore = Keystore 581 + module Otar = Otar 580 582 module Sa = Sa 583 + module Security = Security 581 584 module Cbor = Cbor 582 585 module Ep = Ep 583 586 module Mc = Mc
+3
lib/sdls.mli
··· 78 78 79 79 (** {1 Re-exports} *) 80 80 81 + module Binary = Binary 81 82 module Crypto = Sdls_crypto 82 83 module Cmac = Cmac 83 84 module Hmac = Hmac ··· 85 86 module Key = Key 86 87 module Keyid = Keyid 87 88 module Keystore = Keystore 89 + module Otar = Otar 88 90 module Sa = Sa 91 + module Security = Security 89 92 module Cbor = Cbor 90 93 module Ep = Ep 91 94 module Mc = Mc
+39
sdls-eio.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Eio-based SDLS persistence (SA, keystore, OTAR)" 4 + maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 5 + authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 6 + license: "ISC" 7 + homepage: "https://tangled.org/gazagnaire.org/ocaml-sdls" 8 + bug-reports: "https://tangled.org/gazagnaire.org/ocaml-sdls/issues" 9 + depends: [ 10 + "dune" {>= "3.21"} 11 + "ocaml" {>= "5.1"} 12 + "sdls" {= version} 13 + "eio" {>= "1.0"} 14 + "eio_main" {with-test} 15 + "fpath" {>= "0.7"} 16 + "crypto" {>= "0.1"} 17 + "crypto-rng" {>= "0.1"} 18 + "pbkdf2" {>= "0.1"} 19 + "hkdf" {>= "0.1"} 20 + "fmt" {>= "0.9"} 21 + "logs" {>= "0.7"} 22 + "odoc" {with-doc} 23 + ] 24 + build: [ 25 + ["dune" "subst"] {dev} 26 + [ 27 + "dune" 28 + "build" 29 + "-p" 30 + name 31 + "-j" 32 + jobs 33 + "@install" 34 + "@runtest" {with-test} 35 + "@doc" {with-doc} 36 + ] 37 + ] 38 + dev-repo: "git+https://tangled.org/gazagnaire.org/ocaml-sdls" 39 + x-maintenance-intent: ["(latest)"]