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:
- bless - The encryption process.
- confess - The decryption process.
- chapel - The key exchange process.
- heaven-rx - The red side receiving process.
- heaven-tx - The red side transmitting process.
- purgatory-rx - The black side receiving process.
- purgatory-tx - The black side transmitting process.
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
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 offer_base = KMAC256(ss, "SANCTUM.KEY.OFFER.KDF"), 256-bit traffic_base_rx = KMAC256(ss, "SANCTUM.KEY.TRAFFIC.RX.KDF"), 256-bit traffic_base_tx = KMAC256(ss, "SANCTUM.KEY.TRAFFIC.TX.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) if pt.instance < local_id traffic_key = traffic_base_rx else traffic_key = traffic_base_tx 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 || 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) if pt.instance < local_id traffic_key = traffic_base_tx else traffic_key = traffic_base_rx 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 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.
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 that.
None of them have cool mythology nor provide you with the same type of post-quantum safety or privilege separation as sanctum does though.
Talks
I talked about sanctum at SEC-T 2024.
Source?
Latest release: sanctum 0.9.22
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