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