Latest release: sanctum 0.9.36

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.

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

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.random = PRNG(128-bit), random value for key derivation.

    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 || offer.random

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

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

offer_recv_pk(offer):
    packet = recv()

    now = TIME(WALL_CLOCK), 64-bit
    if packet.now < (now - 10) || packet.now > (now + 10)
        return

    ss = shared symmetrical secret, 256-bit
    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)

    r = offer.random || packet.extra.random
    x = len(ecdh_ss) || ecdh_ss || len(kem_ss) || kem_ss ||
        len(ecdh.pub) || ecdh.pub || len(pt.ecdh.pub) || pt.ecdh.pub ||
        len(r) || r

    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 || offer.random

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

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

offer_recv_ct(offer):
    packet = recv()

    now = TIME(WALL_CLOCK), 64-bit
    if packet.now < (now - 10) || packet.now > (now + 10)
        return

    ss = shared symmetrical secret, 256-bit
    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)

    r = offer.random || packet.extra.random
    x = len(ecdh_ss) || ecdh_ss || len(kem_ss) || kem_ss ||
        len(ecdh.pub) || ecdh.pub || len(pt.ecdh.pub) || pt.ecdh.pub ||
        len(r) || r

    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)