Latest release: sanctum 0.9.20

What is sanctum?

Sanctum is a small, reviewable, capable, pq-safe and fully privilege seperated VPN daemon capable of transporting encrypted network traffic between two peers.

Multi-process

Sanctum is built using a multi-process approach where each process is only doing one thing. This allows for more fine-grained sandboxing in relation to permissions or allowed system calls.

Several different processes exist that all only perform one task:

Packets flow between these processes in a well-defined manner making it impossible to move a packet straight from the red side to the black side without passing the encryption process and vice-versa.

Encryption

Sanctum is post-quantum safe due to its unique approach to deriving session keys based on a shared symmetrical secret in combination with a hybridized asymmetrical exchange. It combines both classic ECDH (x25519) and the PQ-safe NIST standardized ML-KEM-1024.

Traffic is by default encrypted under AES256-GCM with unique keys in both RX and TX directions using a 96-bit nonce consisting of a 32-bit salt and a 64-bit packet counter (see rfc4106).

For management traffic unique encryption keys are derived from the shared secret per message. In this case because the keys are freshly derived the nonces used in this construction are fixed as there is no risk for (key, nonce) pair re-use in this specific scenario.

Key exchange

Ssanctum uses a strong shared symmetrical secret in combination with two asymmetrical secrets (ECDH and ML-KEM-1024) to derive session keys for both RX and TX directions.

The key exchange process looks as follows:

ss = shared symmetrical key, 256-bit, loaded from disk
offer_base = KMAC256(ss, "SANCTUM.KEY.OFFER.KDF"), 256-bit
traffic_base = KMAC256(ss, "SANCTUM.KEY.TRAFFIC.KDF"), 256-bit

derive_offer_encryption_key(seed):
    input = len(seed) || seed
    wk = KMAC256(offer_base, "SANCTUM.SACRAMENT.KDF", input), 256-bit
    return wk

offer_create():
    offer.ecdh = X25519-KEYGEN()
    offer.kem  = ML-KEM-1024-KEYGEN()
    offer.now  = TIME(WALL_CLOCK), 64-bit
    offer.id   = PRNG(64-bit), unique sanctum id
    offer.salt = PRNG(32-bit), salt for nonce construction
    offer.spi  = PRNG(32-bit), the spi for this association

    offer.internal_seed = unused and set to random data
    offer.internal_tag  = unused and set to random data

    return offer

offer_send_pk(offer):
    seed = PRNG(512-bit)
    dk = derive_offer_encryption_key(seed)

    header = 0x53414352414D4E54 || offer.spi || seed
    pt = id || salt || now || internal_seed ||
         offer.ecdh.pub || offer.kem.pk || internal_tag
    encdata = AES256-GCM(dk, nonce=1, aad=header, pt)

    packet.header = header
    packet.data = encdata
    send(packet)

offer_recv_pk(offer):
    packet = recv()

    dk = derive_offer_encryption_key(packet.header.seed)
    pt = AES256-GCM(dk, nonce=1, aad=packet.header, packet.data)

    ecdh_ss = X25519-SCALAR-MULT(pt.ecdh.pub, offer.ecdh.private)
    offer.kem.ct, kem_ss = ML-KEM-1024-ENCAP(pt.kem.pk)

    x = len(ecdh_ss) || ecdh_ss || len(kem_ss) || kem_ss ||
        len(ecdh.pub) || ecdh.pub || len(pt.ecdh.pub) || pt.ecdh.pub
    rx = KMAC256(traffic_base, "SANCTUM.TRAFFIC.KDF", x), 256-bit

    return rx

offer_send_ct(offer):
    seed = PRNG(512-bit)
    dk = derive_offer_encryption_key(seed)

    header = 0x53414352414D4E54 || spi || seed
    pt = id || salt || now || internal_seed ||
         offer.ecdh.pub || offer.kem.ct || internal_tag
    encdata = AES256-GCM(dk, nonce=1, aad=header, pt)

    packet.header = header
    packet.data = encdata
    send(packet)

offer_recv_ct(offer):
    packet = recv()

    dk = derive_offer_encryption_key(packet.header.seed)
    pt = AES256-GCM(dk, nonce=1, aad=packet.header, packet.data)

    ecdh_ss = X25519-SCALAR-MULT(pt.ecdh.pub, offer.ecdh.private)
    kem_ss = ML-KEM-1024-DECAP(offer.kem, pt.kem.ct)

    x = len(ecdh_ss) || ecdh_ss || len(kem_ss) || kem_ss ||
        len(ecdh.pub) || ecdh.pub || len(pt.ecdh.pub) || pt.ecdh.pub
    tx = KMAC256(traffic_base, "SANCTUM.TRAFFIC.KDF", x), 256-bit

    return tx

key exchange:
    my_offer = offer_create()
    peer_offer = offer_create()

    offer_send_pk(my_offer)
    tx = offer_recv_ct(my_offer)

    rx = offer_recv_pk(peer_offer)
    offer_send_ct(peer_offer)

Keys are expired automatically after a given number of packets have been submitted on them (1 << 34), or after 1-hour.

Why did you write sanctum?

I wrote it so I can be certain that my packets are blessed correctly according to the scriptures of cryptology.

Huh?

Ok, I wrote sanctum because I wanted something I can trust fully myself. I am a very private person and want to excercise my right to privacy, even online. There are definitely alternatives, but I opted to carve out something for myself.

Plus, it's cool to hack on stuff.

What makes you qualified to build this?

If you are asking yourself that question, that's ok. The people who know, know. I have been building these type of things for many years at high assurance levels. Now, if this makes you nervous and rather not use Sanctum that is fine, there are plenty of alternatives.

But none of them have cool mythology though ;)

Talks

I talked about sanctum at SEC-T 2024.

Source?

Latest release: sanctum 0.9.20

A mirror of the repository is available on github.

How?

A small simple guide is available here.

I want to contribute!

mail diffs to joris snabel-a sanctorum punkt se