My own corner of monopam
README.md

sdls #

Space Data Link Security (CCSDS 355.0-B-2) for OCaml.

Anyone with an antenna can receive the frames on a spacecraft's command and telemetry links, and on the uplink they can also try to forge them. Space Data Link Security (SDLS, CCSDS 355.0-B-2) protects these frames at the data link layer. Each TC, TM, AOS or USLP frame is encrypted and authenticated under a Security Association (SA), which names the algorithm, the key and the anti-replay state.

sdls implements the protocol in OCaml. It offers authenticated encryption with AES-GCM or AES-CCM, encryption alone with AES-CTR, and authentication with AES-CMAC, HMAC or GMAC. Around the ciphers it keeps security associations, keystores and anti-replay windows, and it handles Over-The-Air Rekeying (OTAR, CCSDS 355.1-B-1). Frame protection works for any frame type, because the caller hands over the raw header bytes and the plaintext. The companion package sdls-eio keeps associations, keys and OTAR state on disk, sealed under a key-encryption key.

The code ships as two opam packages. sdls holds the security associations, keystores, frame protection, OTAR and the wire codecs, and does no I/O. sdls-eio stores that state in files over Eio and derives keys with PBKDF2 and HKDF.

Installation #

Install with opam:

$ opam install sdls sdls-eio

If opam cannot find the packages, they have probably not reached the public opam-repository yet. Add the overlay repository and install from there:

$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam update
$ opam install sdls sdls-eio

Usage #

Algorithm registry #

Each CCSDS algorithm identifier has a matching variant:

# Sdls.Algorithm.int_of_encryption Sdls.Algorithm.AES_256_GCM
- : int = 2
# Sdls.Algorithm.encryption_of_int 4
- : Sdls.Algorithm.encryption option = Some Sdls.Algorithm.AES_256_CCM

Keystore #

The in-memory keystore records where each key is in its life: Pre_active, Active, Deactivated or Destroyed. Keys are held in Secret.t buffers from the secret package. The OCaml runtime never moves these buffers and destroying one erases it, so the garbage collector has no stray copy of a key to leave in the heap:

# let ks = Sdls.Keystore.in_memory () in
  let kid = Sdls.Keyid.of_int_exn 1 in
  let ( let* ) = Result.bind in
  let* () = Sdls.Keystore.add ks kid (Secret.of_bytes (Bytes.make 32 '\x00')) in
  let* () = Sdls.Keystore.activate ks kid in
  Sdls.Keystore.list_by_state ks Sdls.Keystore.Active
  |> List.map (fun k -> Fmt.str "%a" Sdls.Keyid.pp k)
  |> Result.ok
- : (string list, Sdls.Keystore.lifecycle_error) result = Ok ["1"]

add_for_cipher refuses a key of the wrong length for its cipher suite:

# let ks = Sdls.Keystore.in_memory () in
  Sdls.Keystore.add_for_cipher ks (Sdls.Keyid.of_int_exn 2)
    Sdls.Keystore.AES_256
    (Secret.of_bytes (Bytes.make 16 '\x00'))
  |> Result.is_error
- : bool = true

Frame protection #

protect_frame and unprotect_frame work on any frame type. protect_frame takes the raw header bytes and the plaintext. unprotect_frame takes the received frame and the length of its header, which is the prefix authenticated as additional data. Both return the updated SA, and the caller saves the anti-replay state only when the call succeeds:

let protect ~sa ~keys ~frame_hdr ~plaintext =
  Sdls.protect_frame
    ~sa ~keys
    ~frame_hdr_bytes:frame_hdr
    ~plaintext

A busy receiver can call unprotect_frame_into, which decrypts without copying the frame. The frame stays in the string it arrived in, at an offset when it sits inside a larger read. The recovered Frame Data Field goes straight into a buffer the caller owns, for example at the right place in a packet-reassembly buffer. If anything fails, that buffer is left as it was, so a forged frame cannot leave plaintext behind. Use sec_hdr_len to size the destination and peek_spi_string to find the frame's SA, again without a copy.

The SA's ecs and acs fields choose the cipher. With authentication only (AES-CMAC, HMAC-SHA-256, HMAC-SHA-384, HMAC-SHA-512 or GMAC), the frame carries a MAC over the header and the plaintext, and the plaintext travels in the clear. Authenticated encryption (AES-GCM or AES-CCM) replaces the plaintext with ciphertext and appends an authenticator.

Encryption alone (AES-CTR) is the weakest choice. CTR with no acs is unauthenticated and malleable: flip a bit of the ciphertext and the same bit of the plaintext flips. Pair it with an acs (encrypt-then-MAC) unless some other layer authenticates the frame.

Extended Procedures #

Sdls.Recipient runs the Extended Procedures commands (CCSDS 355.1-B-1) that reach the spacecraft: key management, SA management of either direction, and security monitoring and control. It takes the state, the stores and one or more EP PDUs, and returns the new state, the encoded replies and one event per PDU. It does no I/O, so a caller hands the replies to its own link.

A command changes keys and SAs, so it is trusted only as far as the frame that carried it: the standard sends every command over an authenticated channel (section 4.3.1.1) dedicated to the Extended Procedures (section 4.2.1). The PDUs are therefore an Sdls.Ep_payload.t, which only unprotect_ep_frame makes, from a frame verified under an SA configured with ~ep:true. Here the ground protects a Ping under that SA and the spacecraft runs it:

# let gvcid = Sdls.Gvcid.v ~scid:42 ~vcid:3 () in
  let keys = Sdls.Keystore.in_memory () in
  let k = Sdls.Keyid.of_int_exn 1 in
  Result.get_ok (Sdls.Keystore.add keys k (Secret.of_bytes (Bytes.make 32 'k')));
  Result.get_ok (Sdls.Keystore.activate keys k);
  let carrier = Sdls.Sa.v ~ep:true ~spi:(Sdls.Spi.v 7) ~gvcid () in
  let operational =
    Result.bind
      (Sdls.Sa.incoming ~keys carrier
         (Sdls.Sa.Directive.rekey ~ek_id:k ~ak_id:k ()))
      (fun sa -> Sdls.Sa.incoming ~keys sa Sdls.Sa.Directive.Start)
  in
  let sa = Result.get_ok operational in
  let ping =
    Sdls.Ep.encode_header
      (Sdls.Ep.monitoring ~is_reply:false Sdls.Ep.Ping Bytesrw.Bytes.Slice.eod)
  and frame_hdr_bytes = Bytes.of_string "\x20\x2a\x0c\x00\x00" in
  let frame, _ =
    Result.get_ok
      (Sdls.protect_frame ~sa ~keys ~frame_hdr_bytes ~plaintext:ping)
  in
  let pdus, _ =
    Result.get_ok
      (Sdls.unprotect_ep_frame ~sa ~keys
         ~frame_hdr_len:(Bytes.length frame_hdr_bytes) frame)
  in
  let stores =
    { Sdls.Recipient.sa_db = Sdls.Sa.in_memory ();
      otar =
        Sdls.Otar.v
          ~verify_ivs:(Sdls.Otar.Verify_ivs.in_memory ())
          ~master:(Sdls.Keystore.in_memory ())
          ~keys:(Sdls.Keystore.in_memory ()) ();
      log = Sdls.Security.in_memory () }
  in
  let _, replies, events =
    Sdls.Recipient.incoming (Sdls.Recipient.v ()) ~now:0L
      ~self_test:(fun () -> Sdls.Mc.Passed)
      stores pdus
  in
  let hex r =
    String.to_seq r |> List.of_seq
    |> List.map (fun c -> Printf.sprintf "%02x" (Char.code c))
    |> String.concat " "
  in
  ( List.map (fun r -> hex (Sdls.Ep_reply.to_string r)) replies,
    List.map (Fmt.str "%a" Sdls.Recipient.pp_event) events )
- : string list * string list = (["b1 00 00"], ["done SEC_MON_CTRL/MC_PING"])

The same frame unprotected under an SA without ~ep:true is refused with Not_ep_sa, and under one that does not authenticate with Ep_sa_unauthenticated, before the frame is read.

The replies go out on the return link, under the SA that carries the Extended Procedures there (section 4.2.1): Sdls.select_ep names it, and Sdls.protect_ep_frame protects an Sdls.Ep_reply.t, which only the Recipient makes, under it alone.

The standard defines no status code for a refused command, so a refused command sends no reply: the event names the reason (Sdls.Recipient.Refused), and the caller decides what to log. Over Eio, Sdls_eio.Server.unprotect_ep verifies the frame and Sdls_eio.Server.incoming runs the same transition inside the server's critical section, so an SA management directive and the frames it affects never interleave.

Every frame the server receives is reported in the Security Log its configuration names: Sdls.Security.fsr is the Frame Security Report of the last one (CCSDS 355.1-B-1 section 4.2.2), and a rejected frame appends an authentication-failure event and sets the alarm flag. Sdls.report_frame does the same for a receive path built on Sdls.unprotect_frame directly.

Persistence #

sdls-eio writes associations, keystores and OTAR state through a key-value schema. The records are sealed with AES-256-GCM under a key-encryption key, and that key can itself come from a password through PBKDF2. The SA store reserves blocks of counters on disk before it uses them, so after a crash no frame reuses a (key, IV) pair. doc/counter-reservation.mld describes the design.

Limitations #

Cipher suites not implemented #

The library has no AES-256-CBC, AES-256-CBC-MAC or AES-256-GCM-SIV. CBC is an old mode with no authentication, and none of the suites that are implemented needs padding.

The Pad Length field is fully supported and tested byte for byte over AES-256-GCM and AES-256-CTR. There is no interoperability vector against NASA CryptoLib with a non-zero Pad Length, though: CryptoLib only pads (Crypto_TC_Handle_Padding and Crypto_TC_Handle_Enc_Padding) for an AES-256-CBC SA without authentication.

CryptoLib interoperability #

For the implemented suites, the output matches NASA CryptoLib byte for byte, apart from the gaps in the table below, which are open issues in CryptoLib. In the table, shsnf_len is CryptoLib's name for the length of the Security Header Sequence Number Field. That field is the anti-replay counter an SA puts in each frame's security header (CCSDS 355.0-B-2, 4.1.1.4).

Case TC TM AOS Status
AES-256-CCM fails fails fails CryptoLib #498
TM with shsnf_len > 0 n/a fails n/a CryptoLib #499
USLP n/a n/a n/a CryptoLib does not support USLP

Extended Procedures PDUs are protected by SDLS #

CCSDS 355.1-B-1 section 4.1 requires the Space Link Protocol services to carry Extended Procedures PDUs and recommends at least one Security Association for them. An EP PDU is therefore protected by the same SDLS layer it configures. BPSec (RFC 9172) protects bundles in a DTN overlay, which is another layer on another network, so it plays no part here. The Sdls.Ep module documents the PDUs.

Licence #

ISC. See LICENSE.md.