I needed to test my Rust SHA-1 implementation against a more elaborate set of test vectors, such as those supplied from NIST’s Cryptographic Algorithm Validation Program (CAVP). Writing a Rust program that parses the CAVP test vectors and runs my implementation against them is feasible but tedious. Instead, I chose to work with Python’s ctypes module to load my Rust library, then parse and execute the test vectors in Python.

PyO3 is a mature project that really streamlines the process of writing an interface between my Rust code and the Python test harness, but it requires introducing additional dependencies into my Rust project. Since I want my Rust project to remain agnostic to the choice of test harness, I chose to not work with PyO3, but rather export a C-compatible interface in the compiled library.

The first step is to annotate the Rust project to instruct the compiler to create C-compatible structs and function calling interface. For a SHA-1 implementation, there is one struct and three public APIs in Rust:

struct Sha1State {
    hash: [u8; 20],
    buf: [u8; 64],
    buflen: usize,
    bitlen: u64,
}

impl Sha1State {
    pub fn init(&mut self) { ... }
    pub fn update(&mut self, buf: &[u8]) { ... }
    pub fn finalize(&mut self, out: &mut [u8]) { ... }
}

The Rust compiler might introduce a different memory layout for struct Sha1State from a C compiler, so ensuring C compatibility requires annotating the struct with #[repr(C)]:

#[repr(C)]
struct Sha1State { ... }

A second difference between Rust and C lies in the function signatures. Rust’s borrow checker can check on references & and mutable reference &mut, but not on raw pointers used in C. On the other hand, C has no native notion of immutable/mutable references, only raw pointers. So with a Rust function declared with extern "C", &mut self needs to be replaced with *mut Sha1State.

pub extern "C" fn Sha1_init(state: *mut Sha1State) {
    let state = unsafe {
        assert!(!state.is_null());
        &mut *state
    };
    state.init();
}

Notice that in the snippet above, state begins as a raw pointer, but is later dereferenced and borrowed as a mutable reference. Dereferencing a raw pointer is considered unsafe in Rust, hence why the dereferencing is wrapped in an unsafe block.

The same conversion from Rust-style borrows to C-style raw pointers also applies to arrays. &[u8] actually contains both a pointer to the array and a length field, so a Rust function with argument &[u8] needs to be broken down into a tuple of *const u8 and usize, which is then re-assembled back in an unsafe block using std::slice::from_raw_parts. Similarly, a Rust function with argument &mut [u8] needs to be broken down into *mut u8 and usize, then assemble back using std::slice::from_raw_parts_mut:

pub extern "C" fn Sha1_update(state: *mut Sha1State,
                              buf: *const u8,
                              len: usize) {
    let state = unsafe { /* cast *mut Sha1State into &mut Sha1State; */ };
    let buf = unsafe {
        assert!(!buf.is_null());
        std::slice::from_raw_parts(buf, len)
    };
    state.update(buf);
}

pub extern "C" fn Sha1_finalize(state: *mut Sha1State,
                                out: *mut u8,
                                len: usize) {
    let state = unsafe { /* cast *mut Sha1State into &mut Sha1State; */ };
    let out = unsafe {
        assert!(!out.is_null());
        std::slice::from_raw_parts_mut(out, len)
    };
    state.finalize(out);
}

Last but not least, every extern "C" declaration needs to be decorated with #[unsafe(no_mangle)] so that the compiler does not mangle the name in the compiled library.

#[unsafe(no_mangle)]
pub extern "C" fn Sha1_init(...) { ... }

#[unsafe(no_mangle)]
pub extern "C" fn Sha1_update(...) { ... }

#[unsafe(no_mangle)]
pub extern "C" fn Sha1_finalize(...) { ... }

If the names are mangled, it will be difficult to access these functions in Python land. The no_mangle attribute is considered unsafe because without name mangling there is increased risk of symbol name collisions, and it is up to the developer to make sure that there is no duplication.

Python’s ctypes module only supports loading dynamic libraries. For the last step in Rust land, the compiler needs to be told to compile the Rust code into a C-compatible dynamic library by adding the following section into Cargo.toml:

[lib]
crate-type = ["rlib", "cdylib"]

I could verify the output with cargo build [--release], after which I could see target/release/<libname>.dylib. On Linux the dynamic library will likely have .so extension. On Windows it will likely be .dll.

Moving on to Python land. The ctypes module exports a cdll.LoadLibrary function that takes a path to the dynamic library file and returns a CDLL object with which I can access the functions.

from ctypes import cdll
lib = cdll.LoadLibrary("target/release/libtoyssl.dylib")
lib.Sha1_init  # <_FuncPtr object at 0x1061d57e0>

However, before I can call C functions from Python, I need to define the data structure Sha1State, which takes the form of a class that inherits from ctypes.Structure. Within the class I need to define the _fields_ attribute, which is a list of tuples (name, type). In our use case, the two array fields hash: [u8; 20] and buf: [u8; 64] translate to (ctypes.c_uint8 * 20) and (ctypes.c_uint8 * 64) respectively. The two length fields buflen: usize and bitlen: u64 translate to c_size_t and c_uint64 respectively:

class Sha1State(ctypes.Structure):
    hash_bytes = 20
    _fields_ = [
        ("hash", c_uint8 * 20),
        ("buf", c_uint8 * 64),
        ("buflen", c_size_t),
        ("bitlen", c_uint64),
    ]

It is also a good idea to set argtypes and restype for each of the function I will later call so that Python can do some type checking and reduce the risk of memory corruption and/or undefined behaviors. argtypes takes a list of types, and restype takes a single type. For a pointer to a struct which I have already defined the types for, I can wrap it in ctypes.POINTER. The same applies to C arrays, which degenerate to pointers at function call anyway. Note that c_char_p is only for null-terminated C strings, and for binary data, I have to use POINTER(c_uint8). Last but not least, None in Python denotes void in C. Here is the complete specification:

lib.Sha1_init.argtypes = [POINTER(Sha1State)]
lib.Sha1_init.restype = None
lib.Sha1_update.argtypes = [POINTER(Sha1State), POINTER(c_uint8), c_size_t]
lib.Sha1_update.restype = None
# The same types apply to Sha1_finalize

To allocate for a Sha1State struct, one can instantiate an instance of the Sha1State class in Python. This instance can be directly passed to Sha1_init and other public functions as a pointer to the struct:

state = Sha1State()
lib.Sha1_init(state)

Since I typed the argument for Sha1_update and Sha1_finalize to be a pointer to c_uint8, I cannot directly pass Python’s native bytes to these functions. Instead, I have to allocate for a c_uint8 array with c_uint8 * len and copy into the allocated array with from_buffer_copy:

msg = b"abc"
buf = (c_uint8 * len(msg)).from_buffer_copy(msg)
digest = (c_uint8 * Sha1State.hash_bytes)()
lib.Sha1_update(state, buf, len(msg))
lib.Sha1_finalize(state, digest, Sha1State.hash_bytes)

Now I have everything I needed to write the test harness. With Python’s built-in unittest module I can whip up a small test module that loads the dynamic library and test my implementation against all CAVP test cases, all without Python and Rust needing to be aware of each other.

class Sha1State(Structure):
    hash_bytes = 20
    _fields_ = [
        ("hash", c_uint32 * 5),
        ("block", c_uint8 * 64),
        ("block_len", c_size_t),
        ("processed_bits", c_uint64),
    ]


def read_cavp_vectors(path: str) -> list[tuple[int, bytes, bytes]]:
    """Read a CAVP response file, return a list of (msglen, msg, digest). This
    method relies on specific formatting from the CAVP response file:

    Len = 0
    Msg = 00
    MD = da39a3ee5e6b4b0d3255bfef95601890afd80709
    """
    with open(path) as f:
        data = f.readlines()
    cases = []
    for i, line in enumerate(data):
        if not line.startswith("Len = "):
            continue
        msglen = int(line.split()[2]) // 8
        msg = bytes.fromhex(data[i + 1].split()[2])
        digest = bytes.fromhex(data[i + 2].split()[2])
        if not (msglen == 0 and msg == b"\x00"):
            assert msglen == len(msg), f"Expected msglen {msglen}, found {len(msg)}"
        assert (
            len(digest) == Sha1State.hash_bytes
        ), f"Expected digestlen {Sha1State.hash_bytes}, found {len(digest)}"
        cases.append((msglen, msg, digest))
    return cases


class TestSha1(unittest.TestCase):
    def setUp(self):
        self.libtoyssl = cdll.LoadLibrary(str(TOYSSL_DYLIB_PATH))
        self.libtoyssl.Sha1_init.argtypes = [POINTER(Sha1State)]
        self.libtoyssl.Sha1_init.restype = None
        self.libtoyssl.Sha1_update.argtypes = [
            POINTER(Sha1State),
            POINTER(c_uint8),
            c_size_t,
        ]
        self.libtoyssl.Sha1_update.restype = None
        self.libtoyssl.Sha1_finalize.argtypes = [
            POINTER(Sha1State),
            POINTER(c_uint8),
            c_size_t,
        ]
        self.libtoyssl.Sha1_finalize.restype = None
        self.hasher = Sha1State()
        self.hash = (c_uint8 * Sha1State.hash_bytes)()

    def test_cavp(self):
        for test_suite_name in ["Sha1ShortMsg.rsp", "Sha1LongMsg.rsp"]:
            cases = read_cavp_vectors(str(TOYSSL_TEST_DIR / test_suite_name))
            for i, (msglen, msg_, digest) in enumerate(cases):
                msg = (c_uint8 * msglen).from_buffer_copy(msg_)
                with self.subTest(suite=test_suite_name, case_id=i):
                    self.libtoyssl.Sha1_init(self.hasher)
                    self.libtoyssl.Sha1_update(self.hasher, msg, msglen)
                    self.libtoyssl.Sha1_finalize(
                        self.hasher, self.hash, Sha1State.hash_bytes
                    )
                    self.assertEqual(bytes(self.hash), digest)

References