Wrapping a C Library with Zig

This post is going to look at wrapping a C library in Zig. The library to wrap will be libsodium, a "modern, easy-to-use software library for encryption, decryption, signatures, password hashing and more."

Setup

I'm going to assume you've already got Zig installed. This was last updated in March of 2024 with a build off the master branch, which mostly looks like 0.12.0. Zig is still in development, and the number of breaking changes between here and 1.0 is more than 0, so if you're reading this in the future you may have to adjust some things.

I'm also assuming a version of libsodium along with development headers is installed. If you're running a sensible Linux distro, you should be able to install a package named something like libsodium-dev. I've got libsodium23 and libsodium-dev 1.0.18-1 installed on my Debian system.

The first thing to do is set up the project. $ zig init sets us up with a build.zig and a src directory with a boilerplate main.zig and root.zig. Let's start by getting rid of main.zig, renaming root.zig to sodium.zig and updating build.zig with some libraries to link and main's new name.

// build.zig
const Build = @import("std").Build;

pub fn build(b: *Build) void {
    const target = b.standardTargetOptions(.{});
    const mode = b.standardOptimizationOptions(.{});
    const lib = b.addStaticLibrary(.{
        .name = "sodium",
        .root_source_file = .{ .path = "src/sodium.zig" },
        .target = target,
        .optimize = mode,
    });
    b.installArtifact(lib);

    var tests = b.addTest(.{
        .root_source_file = .{ .path = "src/sodium.zig" },
        .target = target,
        .optimize = mode,
    });

    tests.linkSystemLibrary("c");
    tests.linkSystemLibrary("sodium");

    const run_tests = b.addRunArtifact(tests);

    const test_step = b.step("test", "Run library tests");
    test_step.dependOn(&run_tests.step);
}

Note that I'm just making a library, so the only thing that gets linked to libc and libsodium is the tests target.

Wrap a Function

Now let's open up sodium.zig and put in something really basic that will at least compile and link for our tests.

// sodium.zig
const c = @cImport({
    @cInclude("sodium.h");
});

/// Initialize libsodium. Call before other libsodium functions.
pub fn init() !void {
    if (c.sodium_init() < 0) {
        return error.InitError;
    }
}
test "initialize" {
    try init();
}

It doesn't look like there's a lot going on here, but this little file has all the tools we're going to use. The first line instructs Zig to translate "sodium.h" to Zig and import it into a module called "c". That's the magic. Then we take sodium_init, which the documentation says returns 0 on success, 1 if already initialized, and -1 on failure, and make it return a Zig error when it fails. There is a little bit of editorial decision making here, since the 1 return code is being ignored, but I think success is success and I don't care about finer detail.

The test actually uses our init function, and now we have something we can run to see if it works.

$ zig build test
All 1 tests passed.

Hooray! On to bigger and more interesting things.

Wrapping

The first thing we want to do is make an error set that we can use for this library. Every error we throw is going to be because libsodium returned some error code, and it's nice to group them together. So let's make an errors.zig that just has a SodiumError type that other modules can import.

// errors.zig
pub const SodiumError = error{
    InitError,
};

There, that'll be useful in init(). Here is an updated sodium.zig that uses it.

// sodium.zig
const c = @cImport({
    @cInclude("sodium.h");
});

pub const SodiumError = @import("errors.zig").SodiumError;

/// Initialize libsodium. Call before other libsodium functions.
pub fn init() SodiumError!void {
    if (c.sodium_init() < 0) {
        return SodiumError.InitError;
    }
}
test "initialize" {
    try init();
}

Of course, Zig can infer return error sets, so for the rest of this we'll just have functions return !void if they would have returned SodiumError!void.

Tests pass, good to move on. The first module I am going to convert is crypto_box. This bit of the library lets us generate public/private key pairs and encrypt messages using the public key that can only be decrypted by the private key. Sounds handy.

The first thing to do here is create a new file, crypto_box.zig, with something small that we can get working. We'll wrap the crypto_box_keypair function first, since it's a prerequisite for the rest of the library. It is also nice and small, which makes the job easier.

// crypto_box.zig
const std = @import("std");
const SodiumError = @import("errors.zig").SodiumError;

const c = @cImport({
    @cInclude("sodium.h");
});

pub const PUBLICKEYBYTES = c.crypto_box_PUBLICKEYBYTES;
pub const SECRETKEYBYTES = c.crypto_box_SECRETKEYBYTES;

/// Generate a public/private key pair for use in other functions in
/// this module.
pub fn keyPair(
    pub_key: *[PUBLICKEYBYTES]u8,
    secret_key: *[SECRETKEYBYTES]u8,
) !void {
    if (c.crypto_box_keypair(pub_key, secret_key) != 0) {
        return SodiumError.KeyGenError;
    }
}

const sodium = @import("sodium.zig");

test "generate key" {
    var pub_key: [PUBLICKEYBYTES]u8 = undefined;
    var secret_key: [SECRETKEYBYTES]u8 = undefined;
    try sodium.init();
    try keyPair(&pub_key, &secret_key);
}

Note the new KeyGenError, which I added to errors.zig. This is a nice improvement on the interface, since it eliminates the whole class of errors where the buffers passed to crypto_box_keypair are the wrong size. It also guarantees error checking, since Zig won't let you just call sodium.crypto_box.keyPair without catching errors.

Of course, this file by itself does nothing. Now we have to update sodium.zig to run the test.

const c = @cImport({
    @cInclude("sodium.h");
});

pub const crypto_box = @import("crypto_box.zig");
pub const SodiumError = @import("errors.zig").SodiumError;

/// Initialize libsodium. Call before other libsodium functions.
pub fn init() !void {
    if (c.sodium_init() < 0) {
        return SodiumError.InitError;
    }
}

test "initialize" {
    try init();
}

test "sodium" {
    _ = @import("crypto_box.zig");
}

There are a few things to notice here. First, I exported our new module as crypto_box. Anyone who uses this wrapper library will have a line something like const sodium = @import("sodium"); and we want those users to be able to get to sodium.crypto_box. Second, I added a new "sodium" test. This is a Zig idiom for running tests in exported modules. We don't have to make any changes to build.zig. The output of $ zig build test now looks like this:

$ zig build test
All 3 tests passed.

The Fun Part

The code examples are going to start getting long, so instead of reproducing entire files here, I'll point you to sourcehut or, if you really prefer, github where I've made this project available.

Instead of dumping whole files, I'll highlight some ways that Zig lets us translate awkward and bug-prone C interfaces to easy-to-use Zig interfaces. First on the list is crypto_box_seal. This function is for anonymously sending messages to a recipient whose public key is known.

/// Turn an arbitrary length message into a ciphertext. ciphertext
/// argument must be (message length + SEALBYTES) long.
pub fn seal(
    ciphertext: []u8,
    message: []const u8,
    recipient_pk: *const [PUBLICKEYBYTES]u8,
) !void {
    const msgLen = message.len;
    const ctxtLen = ciphertext.len;
    if (ctxtLen < msgLen + SEALBYTES) {
        return SodiumError.SealError;
    }

    const cbSeal = c.crypto_box_seal;
    if (cbSeal(ciphertext.ptr, message.ptr, msgLen, recipient_pk) != 0) {
        return SodiumError.SealError;
    }
}

The C interface takes 3 pointers and a message length. It's up to the user to read the documentation and ensure that the ciphertext buffer is the right length, and that the message length is correctly calculated. A user of the C library also has to check error codes and make sure they're not writing garbage out because something went wrong.

The Zig interface, on the other hand, completely avoids the buffer overflow issue and drops the mlen argument, since slices know how long they are. It also takes special effort to ignore an error return, as opposed to C where it takes effort to handle it correctly.

Another thing I've barely mentioned is the namespacing. I took a function called crypto_box_seal and replaced it with one just called seal. This is possible because Zig has a sensible namespacing scheme, so users can call it sodium.crypto_box.seal or crypto_box.seal or seal depending on how they import it, with no risk of collision.

The other topic I want to cover here is wrapping an interface in a higher level abstraction. This isn't strictly necessary, but since Zig's standard library knows about streams, we can make things like secretstream.zig's ChunkEncrypter which takes an output stream and a secret key and pushes fixed-sized chunks of encrypted data to it.

Conclusion

The benefits we can get from wrapping a C library in zig are similar to many higher-level-than-C languages, and some of those have easy-to-use FFIs. However, few are as effortless and I've yet to run into any that are as fun to use.