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.