Latest release: sanctum 0.9.34

What is Sanctum?

Sanctum is a small, reviewable, capable, pq-secure and fully privilege separated VPN daemon for OpenBSD, Linux and MacOS.

Sanctum its unique privilege separation design in combination with strong modern sandboxing guarantee that all of its important assets are separated from processes that talk to the internet or handle non-cryptography related things.

Sanctum can establish traditional site-to-site L2 or L3 tunnels, one-way tunnels and even tunnels between peers when both peers are behind NAT using hole-punching and Sanctum's cathedrals for peer discovery.

See The Reliquary, a community driven Sanctum cathedral setup. Note that it is entirely possible to set up your own cathedrals.

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.

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 secure 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-secure 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

Sanctum 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
traffic_base_rx = sanctum_base_key(ss, PURPOSE_RX)
traffic_base_tx = sanctum_base_key(ss, PURPOSE_TX)

sanctum_base_key(key, purpose):
    cathedral_flock = flock tunnel belongs too, or 0 if no cathedral in use
    cathedral_flock_dst = flock destination tunnels belongs too, or 0 when
                          talking to a cathedral or no cathedral is in use

    if cathedral_flock <= cathedral_flock_dst:
        flock_a = cathedral_flock
        flock_b = cathedral_flock_dst
    else:
        flock_a = cathedral_flock_dst
        flock_b = cathedral_flock

    if purpose == PURPOSE_OFFER:
        label = "SANCTUM.OFFER.KDF"
    else if purpose == PURPOSE_RX_KEY:
        label = "SANCTUM.KEY.TRAFFIC.RX.KDF"
    else if purpose == PURPOSE_TX_KEY:
        label = "SANCTUM.KEY.TRAFFIC.TX.KDF"

    x = len(flock_a) || flock_a || len(flock_b) || flock_b
    base_key = KMAC256(key, label, x), 256-bit

    return base_key

derive_offer_encryption_key(seed):
    x = len(seed) || seed
    ss = shared symmetrical secret, 256-bit
    key = sanctum_base_key(ss, PURPOSE_OFFER)
    wk = KMAC256(key, "SANCTUM.SACRAMENT.KDF", x), 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 || flock_src || flock_dst || offer.spi || seed

    data = SANCTUM_OFFER_TYPE_EXCHANGE || offer.now ||
           offer.id || offer.spi || offer.salt ||
           SANCTUM_OFFER_STATE_KEM_PK_FRAGMENT ||
           offer.ecdh.pub || offer.kem.pk

    encdata = AES256-GCM(dk, nonce=1, aad=header, data)

    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)

    if pt.instance < local_id
        traffic_key = sanctum_base_key(ss, PURPOSE_RX_KEY)
    else
        traffic_key = sanctum_base_key(ss, PURPOSE_TX_KEY)

    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_key, "SANCTUM.TRAFFIC.KDF", x), 256-bit

    return rx

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

    header = 0x53414352414D4E54 || flock_src || flock_dst || offer.spi || seed

    data = SANCTUM_OFFER_TYPE_EXCHANGE || offer.now ||
           offer.id || offer.spi || offer.salt ||
           SANCTUM_OFFER_STATE_KEM_CT_FRAGMENT ||
           offer.ecdh.pub || offer.kem.ct

    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)

    if pt.instance < local_id
        traffic_key = sanctum_base_key(ss, PURPOSE_TX_KEY)
    else
        traffic_key = sanctum_base_key(ss, PURPOSE_RX_KEY)

    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_key, "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 would I want to use this?

Well, you don't have to. I built Sanctum for me and my hacker friends with the many years of experience I have building this type of stuff at very high assurance levels. There are plenty of alternatives out there.

None of them have cool mythology nor provide you with the same type of post-quantum security or privilege separation as Sanctum does though.

Another benefit is the ability to setup your own entire cathedral network allowing you to build a distributed infrastructure so your devices can always discover and talk to each other no matter what.

Talks

Source?

Latest release: sanctum 0.9.34

A mirror of the repository is available on github.
A library that implements the Sanctum protocol can be found here.

How?

A small simple guide is available here.

I want to contribute!

mail diffs to joris snabel-a sanctorum punkt se