496 lines
17 KiB
Zig
496 lines
17 KiB
Zig
//! RFC-0830 Section 2.3: PQXDH Protocol
|
||
//!
|
||
//! Post-Quantum Extended Diffie-Hellman Key Agreement
|
||
//!
|
||
//! This module implements hybrid key agreement combining:
|
||
//! - 4× X25519 elliptic curve handshakes (classical)
|
||
//! - 1× ML-KEM-768 post-quantum key encapsulation (when liboqs available)
|
||
//! - HKDF-SHA256 to combine shared secrets into root key
|
||
//!
|
||
//! Security: Attacker must break BOTH X25519 AND ML-KEM-768 to compromise
|
||
//! This provides defense against "harvest now, decrypt later" attacks.
|
||
//!
|
||
//! NOTE: When liboqs is not available, falls back to classical X25519 only.
|
||
//! Production deployments MUST link liboqs for post-quantum security.
|
||
//! Build with: zig build -Denable-liboqs=true
|
||
|
||
const std = @import("std");
|
||
const crypto = std.crypto;
|
||
const builtin = @import("builtin");
|
||
|
||
// Import liboqs module (real or stub based on build config)
|
||
const liboqs = @import("liboqs");
|
||
|
||
// Compile-time feature gating: check if liboqs is actually available
|
||
pub const enable_pq = liboqs.isAvailable();
|
||
|
||
/// Check if post-quantum crypto is enabled at build time
|
||
pub const pq_enabled = enable_pq;
|
||
|
||
// ============================================================================
|
||
// Global mutex to protect RNG state during deterministic generation
|
||
// ============================================================================
|
||
|
||
var rng_mutex = std.Thread.Mutex{};
|
||
var deterministic_rng: std.crypto.hash.sha3.Shake256 = undefined;
|
||
|
||
fn custom_rng_callback(dest: [*]u8, len: usize) callconv(.c) void {
|
||
deterministic_rng.squeeze(dest[0..len]);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Error types
|
||
// ============================================================================
|
||
|
||
pub const PQError = error{
|
||
ML_KEM_NotAvailable,
|
||
ML_KEM_KeygenFailed,
|
||
ML_KEM_EncapsFailed,
|
||
ML_KEM_DecapsFailed,
|
||
};
|
||
|
||
// ============================================================================
|
||
// Constants
|
||
// ============================================================================
|
||
|
||
pub const ML_KEM_768 = struct {
|
||
pub const PUBLIC_KEY_SIZE = 1184;
|
||
pub const SECRET_KEY_SIZE = 2400;
|
||
pub const CIPHERTEXT_SIZE = 1088;
|
||
pub const SHARED_SECRET_SIZE = 32;
|
||
pub const SECURITY_LEVEL = 3; // NIST Level 3 (≈AES-192)
|
||
};
|
||
|
||
pub const X25519 = struct {
|
||
pub const PUBLIC_KEY_SIZE = 32;
|
||
pub const PRIVATE_KEY_SIZE = 32;
|
||
pub const SHARED_SECRET_SIZE = 32;
|
||
};
|
||
|
||
/// Total public key size for PQXDH (4× X25519 + 1× ML-KEM-768)
|
||
pub const PQXDH_PUBLIC_KEY_SIZE = 4 * X25519.PUBLIC_KEY_SIZE + ML_KEM_768.PUBLIC_KEY_SIZE;
|
||
|
||
/// Total secret key size
|
||
pub const PQXDH_SECRET_KEY_SIZE = 4 * X25519.PRIVATE_KEY_SIZE + ML_KEM_768.SECRET_KEY_SIZE;
|
||
|
||
/// Ciphertext size (ML-KEM-768 encapsulation output)
|
||
pub const PQXDH_CIPHERTEXT_SIZE = ML_KEM_768.CIPHERTEXT_SIZE;
|
||
|
||
// ============================================================================
|
||
// Key Types
|
||
// ============================================================================
|
||
|
||
pub const KeyPair = struct {
|
||
public_key: [ML_KEM_768.PUBLIC_KEY_SIZE]u8,
|
||
secret_key: [ML_KEM_768.SECRET_KEY_SIZE]u8,
|
||
};
|
||
|
||
/// PQXDH Identity Keypair (long-term)
|
||
pub const IdentityKeyPair = struct {
|
||
/// 4 X25519 keys + 1 ML-KEM-768 key
|
||
x25519_keys: [4][X25519.PUBLIC_KEY_SIZE]u8,
|
||
mlkem_keypair: KeyPair,
|
||
/// Secret keys for X25519
|
||
x25519_secrets: [4][X25519.PRIVATE_KEY_SIZE]u8,
|
||
|
||
pub fn generate(allocator: std.mem.Allocator, seed: [64]u8) !IdentityKeyPair {
|
||
_ = allocator;
|
||
var kp: IdentityKeyPair = undefined;
|
||
|
||
// Derive 4 X25519 keypairs from seed using HKDF
|
||
const salt = "Libertaria_PQXDH_Identity_v1";
|
||
const prk = crypto.kdf.hkdf.HkdfSha256.extract(salt, seed[0..32]);
|
||
|
||
for (0..4) |i| {
|
||
var okm: [64]u8 = undefined;
|
||
var ctx: [6]u8 = undefined;
|
||
@memcpy(ctx[0..4], "key_");
|
||
ctx[4] = '0' + @as(u8, @intCast(i));
|
||
ctx[5] = 0;
|
||
crypto.kdf.hkdf.HkdfSha256.expand(&okm, ctx[0..5], prk);
|
||
|
||
// X25519 scalar clamping
|
||
var scalar: [32]u8 = undefined;
|
||
@memcpy(&scalar, okm[0..32]);
|
||
scalar[0] &= 248;
|
||
scalar[31] &= 127;
|
||
scalar[31] |= 64;
|
||
|
||
kp.x25519_secrets[i] = scalar;
|
||
// Generate public key from scalar
|
||
kp.x25519_keys[i] = try crypto.dh.X25519.recoverPublicKey(scalar);
|
||
}
|
||
|
||
// Generate ML-KEM-768 keypair from seed[32..64]
|
||
var ml_seed: [32]u8 = undefined;
|
||
@memcpy(&ml_seed, seed[32..64]);
|
||
kp.mlkem_keypair = try generateKeypairFromSeed(ml_seed);
|
||
|
||
return kp;
|
||
}
|
||
};
|
||
|
||
// ============================================================================
|
||
// ML-KEM-768 Operations
|
||
// ============================================================================
|
||
|
||
/// Generate ML-KEM-768 keypair deterministically from a 32-byte seed
|
||
/// Thread-safe via global mutex (liboqs RNG is global state)
|
||
pub fn generateKeypairFromSeed(seed: [32]u8) !KeyPair {
|
||
if (!enable_pq) {
|
||
return PQError.ML_KEM_NotAvailable;
|
||
}
|
||
|
||
rng_mutex.lock();
|
||
defer rng_mutex.unlock();
|
||
|
||
// 1. Initialize deterministic RNG with seed
|
||
deterministic_rng = std.crypto.hash.sha3.Shake256.init(.{});
|
||
const domain = "Libertaria_ML-KEM-768_Seed_v1";
|
||
deterministic_rng.update(domain);
|
||
deterministic_rng.update(&seed);
|
||
|
||
// 2. Switch liboqs to use our custom callback
|
||
liboqs.OQS_randombytes_custom_algorithm(custom_rng_callback);
|
||
|
||
// 3. Generate keypair (uses our deterministic RNG)
|
||
var kp: KeyPair = undefined;
|
||
const ret = liboqs.OQS_KEM_ml_kem_768_keypair(
|
||
&kp.public_key,
|
||
&kp.secret_key,
|
||
);
|
||
|
||
// 4. Restore system RNG (important!)
|
||
_ = liboqs.OQS_randombytes_switch_algorithm("system");
|
||
|
||
if (ret != 0) {
|
||
return PQError.ML_KEM_KeygenFailed;
|
||
}
|
||
|
||
return kp;
|
||
}
|
||
|
||
/// Generate ML-KEM-768 keypair using system RNG (non-deterministic)
|
||
pub fn generateKeypair() !KeyPair {
|
||
if (!enable_pq) {
|
||
return PQError.ML_KEM_NotAvailable;
|
||
}
|
||
|
||
// Switch to system RNG
|
||
_ = liboqs.OQS_randombytes_switch_algorithm("system");
|
||
|
||
var kp: KeyPair = undefined;
|
||
const ret = liboqs.OQS_KEM_ml_kem_768_keypair(
|
||
&kp.public_key,
|
||
&kp.secret_key,
|
||
);
|
||
|
||
if (ret != 0) {
|
||
return PQError.ML_KEM_KeygenFailed;
|
||
}
|
||
|
||
return kp;
|
||
}
|
||
|
||
/// Encapsulate: Create shared secret + ciphertext from public key
|
||
pub fn encapsulate(public_key: [ML_KEM_768.PUBLIC_KEY_SIZE]u8) !struct { ciphertext: [ML_KEM_768.CIPHERTEXT_SIZE]u8, shared_secret: [ML_KEM_768.SHARED_SECRET_SIZE]u8 } {
|
||
if (!enable_pq) {
|
||
return PQError.ML_KEM_NotAvailable;
|
||
}
|
||
|
||
var result: struct { ciphertext: [ML_KEM_768.CIPHERTEXT_SIZE]u8, shared_secret: [ML_KEM_768.SHARED_SECRET_SIZE]u8 } = undefined;
|
||
|
||
const ret = liboqs.OQS_KEM_ml_kem_768_encaps(
|
||
&result.ciphertext,
|
||
&result.shared_secret,
|
||
&public_key,
|
||
);
|
||
|
||
if (ret != 0) {
|
||
return PQError.ML_KEM_EncapsFailed;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// Decapsulate: Recover shared secret from ciphertext using secret key
|
||
pub fn decapsulate(ciphertext: [ML_KEM_768.CIPHERTEXT_SIZE]u8, secret_key: [ML_KEM_768.SECRET_KEY_SIZE]u8) ![ML_KEM_768.SHARED_SECRET_SIZE]u8 {
|
||
if (!enable_pq) {
|
||
return PQError.ML_KEM_NotAvailable;
|
||
}
|
||
|
||
var shared_secret: [ML_KEM_768.SHARED_SECRET_SIZE]u8 = undefined;
|
||
|
||
const ret = liboqs.OQS_KEM_ml_kem_768_decaps(
|
||
&shared_secret,
|
||
&ciphertext,
|
||
&secret_key,
|
||
);
|
||
|
||
if (ret != 0) {
|
||
return PQError.ML_KEM_DecapsFailed;
|
||
}
|
||
|
||
return shared_secret;
|
||
}
|
||
|
||
// ============================================================================
|
||
// PQXDH Prekey Bundle
|
||
// ============================================================================
|
||
|
||
pub const PrekeyBundle = struct {
|
||
/// Long-term identity key (Ed25519 public key)
|
||
identity_key: [32]u8,
|
||
|
||
/// Medium-term signed prekey (X25519 public key)
|
||
signed_prekey_x25519: [X25519.PUBLIC_KEY_SIZE]u8,
|
||
|
||
/// Signature of signed_prekey_x25519 by identity_key (Ed25519)
|
||
signed_prekey_signature: [64]u8,
|
||
|
||
/// Post-quantum signed prekey (ML-KEM-768 public key)
|
||
signed_prekey_mlkem: [ML_KEM_768.PUBLIC_KEY_SIZE]u8,
|
||
|
||
/// One-time ephemeral prekey (X25519 public key)
|
||
one_time_prekey_x25519: [X25519.PUBLIC_KEY_SIZE]u8,
|
||
|
||
/// One-time ephemeral prekey (ML-KEM-768 public key)
|
||
one_time_prekey_mlkem: [ML_KEM_768.PUBLIC_KEY_SIZE]u8,
|
||
|
||
/// Serialize bundle to bytes for transmission
|
||
pub fn toBytes(self: *const PrekeyBundle, allocator: std.mem.Allocator) ![]u8 {
|
||
const total_size = 32 + 32 + 64 + ML_KEM_768.PUBLIC_KEY_SIZE + 32 + ML_KEM_768.PUBLIC_KEY_SIZE;
|
||
var buffer = try allocator.alloc(u8, total_size);
|
||
var offset: usize = 0;
|
||
|
||
@memcpy(buffer[offset .. offset + 32], &self.identity_key);
|
||
offset += 32;
|
||
|
||
@memcpy(buffer[offset .. offset + 32], &self.signed_prekey_x25519);
|
||
offset += 32;
|
||
|
||
@memcpy(buffer[offset .. offset + 64], &self.signed_prekey_signature);
|
||
offset += 64;
|
||
|
||
@memcpy(buffer[offset .. offset + ML_KEM_768.PUBLIC_KEY_SIZE], &self.signed_prekey_mlkem);
|
||
offset += ML_KEM_768.PUBLIC_KEY_SIZE;
|
||
|
||
@memcpy(buffer[offset .. offset + 32], &self.one_time_prekey_x25519);
|
||
offset += 32;
|
||
|
||
@memcpy(buffer[offset .. offset + ML_KEM_768.PUBLIC_KEY_SIZE], &self.one_time_prekey_mlkem);
|
||
|
||
return buffer;
|
||
}
|
||
|
||
/// Deserialize bundle from bytes
|
||
pub fn fromBytes(_: std.mem.Allocator, data: []const u8) !PrekeyBundle {
|
||
const expected_size = 32 + 32 + 64 + ML_KEM_768.PUBLIC_KEY_SIZE + 32 + ML_KEM_768.PUBLIC_KEY_SIZE;
|
||
if (data.len < expected_size) {
|
||
return error.InvalidBundleSize;
|
||
}
|
||
|
||
var bundle: PrekeyBundle = undefined;
|
||
var offset: usize = 0;
|
||
|
||
@memcpy(&bundle.identity_key, data[offset .. offset + 32]);
|
||
offset += 32;
|
||
|
||
@memcpy(&bundle.signed_prekey_x25519, data[offset .. offset + 32]);
|
||
offset += 32;
|
||
|
||
@memcpy(&bundle.signed_prekey_signature, data[offset .. offset + 64]);
|
||
offset += 64;
|
||
|
||
@memcpy(&bundle.signed_prekey_mlkem, data[offset .. offset + ML_KEM_768.PUBLIC_KEY_SIZE]);
|
||
offset += ML_KEM_768.PUBLIC_KEY_SIZE;
|
||
|
||
@memcpy(&bundle.one_time_prekey_x25519, data[offset .. offset + 32]);
|
||
offset += 32;
|
||
|
||
@memcpy(&bundle.one_time_prekey_mlkem, data[offset .. offset + ML_KEM_768.PUBLIC_KEY_SIZE]);
|
||
|
||
return bundle;
|
||
}
|
||
};
|
||
|
||
// ============================================================================
|
||
// PQXDH Handshake
|
||
// ============================================================================
|
||
|
||
/// PQXDH ephemeral keypair (per-session)
|
||
pub const EphemeralKeyPair = struct {
|
||
x25519: struct {
|
||
public: [X25519.PUBLIC_KEY_SIZE]u8,
|
||
secret: [X25519.PRIVATE_KEY_SIZE]u8,
|
||
},
|
||
mlkem_seed: [32]u8,
|
||
};
|
||
|
||
/// Generate ephemeral keypair for PQXDH handshake
|
||
pub fn generateEphemeral(allocator: std.mem.Allocator) !EphemeralKeyPair {
|
||
_ = allocator;
|
||
var ekp: EphemeralKeyPair = undefined;
|
||
|
||
// Generate X25519 ephemeral
|
||
const x25519_sk = crypto.dh.X25519.randomCli(&std.crypto.random);
|
||
ekp.x25519.secret = x25519_sk;
|
||
ekp.x25519.public = try crypto.dh.X25519.recoverPublicKey(x25519_sk);
|
||
|
||
// Generate ML-KEM seed
|
||
std.crypto.random.bytes(&ekp.mlkem_seed);
|
||
|
||
return ekp;
|
||
}
|
||
|
||
/// PQXDH Initiator -> Responder message
|
||
pub const PQXDHInitMessage = struct {
|
||
x25519_ephemeral: [4][X25519.PUBLIC_KEY_SIZE]u8,
|
||
mlkem_ciphertext: [ML_KEM_768.CIPHERTEXT_SIZE]u8,
|
||
};
|
||
|
||
/// Perform PQXDH as Initiator
|
||
pub fn initiatorHandshake(
|
||
allocator: std.mem.Allocator,
|
||
responder_identity: IdentityKeyPair,
|
||
our_ephemeral: EphemeralKeyPair,
|
||
) !struct { message: PQXDHInitMessage, root_key: [32]u8 } {
|
||
// Generate 4 X25519 shared secrets
|
||
var x25519_secrets: [4][32]u8 = undefined;
|
||
for (0..4) |i| {
|
||
x25519_secrets[i] = try crypto.dh.X25519.scalarmult(
|
||
our_ephemeral.x25519.secret,
|
||
responder_identity.x25519_keys[i],
|
||
);
|
||
}
|
||
|
||
// ML-KEM encapsulation
|
||
const encaps_result = try encapsulate(responder_identity.mlkem_keypair.public_key);
|
||
|
||
// Build message
|
||
var message: PQXDHInitMessage = undefined;
|
||
for (0..4) |i| {
|
||
message.x25519_ephemeral[i] = our_ephemeral.x25519.public;
|
||
}
|
||
message.mlkem_ciphertext = encaps_result.ciphertext;
|
||
|
||
// Derive root key using HKDF-SHA256
|
||
var ikm: [4 * 32 + 32]u8 = undefined;
|
||
for (0..4) |i| {
|
||
@memcpy(ikm[i * 32 .. (i + 1) * 32], &x25519_secrets[i]);
|
||
}
|
||
@memcpy(ikm[4 * 32 ..], &encaps_result.shared_secret);
|
||
|
||
var root_key: [32]u8 = undefined;
|
||
const salt = "Libertaria_PQXDH_RootKey_v1";
|
||
crypto.kdf.hkdf.HkdfSha256.extractAndExpand(&root_key, salt, ikm, "");
|
||
|
||
_ = allocator;
|
||
return .{ .message = message, .root_key = root_key };
|
||
}
|
||
|
||
/// Perform PQXDH as Responder
|
||
pub fn responderHandshake(
|
||
allocator: std.mem.Allocator,
|
||
our_identity: IdentityKeyPair,
|
||
init_message: PQXDHInitMessage,
|
||
) ![32]u8 {
|
||
// Recover 4 X25519 shared secrets
|
||
var x25519_secrets: [4][32]u8 = undefined;
|
||
for (0..4) |i| {
|
||
x25519_secrets[i] = try crypto.dh.X25519.scalarmult(
|
||
our_identity.x25519_secrets[i],
|
||
init_message.x25519_ephemeral[i],
|
||
);
|
||
}
|
||
|
||
// ML-KEM decapsulation
|
||
const mlkem_secret = try decapsulate(
|
||
init_message.mlkem_ciphertext,
|
||
our_identity.mlkem_keypair.secret_key,
|
||
);
|
||
|
||
// Derive root key
|
||
var ikm: [4 * 32 + 32]u8 = undefined;
|
||
for (0..4) |i| {
|
||
@memcpy(ikm[i * 32 .. (i + 1) * 32], &x25519_secrets[i]);
|
||
}
|
||
@memcpy(ikm[4 * 32 ..], &mlkem_secret);
|
||
|
||
var root_key: [32]u8 = undefined;
|
||
const salt = "Libertaria_PQXDH_RootKey_v1";
|
||
crypto.kdf.hkdf.HkdfSha256.extractAndExpand(&root_key, salt, ikm, "");
|
||
|
||
_ = allocator;
|
||
return root_key;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Tests
|
||
// ============================================================================
|
||
|
||
test "ML-KEM-768 deterministic keypair generation" {
|
||
if (!pq_enabled) {
|
||
std.log.warn("Skipping PQ test: liboqs not enabled", .{});
|
||
return error.SkipZigTest;
|
||
}
|
||
|
||
const seed = [32]u8{ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20 };
|
||
|
||
const kp1 = try generateKeypairFromSeed(seed);
|
||
const kp2 = try generateKeypairFromSeed(seed);
|
||
|
||
// Deterministic: same seed -> same keys
|
||
try std.testing.expectEqual(kp1.public_key, kp2.public_key);
|
||
try std.testing.expectEqual(kp1.secret_key, kp2.secret_key);
|
||
}
|
||
|
||
test "PQXDH full handshake" {
|
||
if (!pq_enabled) {
|
||
std.log.warn("Skipping PQ test: liboqs not enabled", .{});
|
||
return error.SkipZigTest;
|
||
}
|
||
|
||
const allocator = std.testing.allocator;
|
||
|
||
// Generate responder identity
|
||
const responder_seed = [64]u8{ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40 };
|
||
const responder = try IdentityKeyPair.generate(allocator, responder_seed);
|
||
|
||
// Generate initiator ephemeral
|
||
const initiator_ephemeral = try generateEphemeral(allocator);
|
||
|
||
// Initiator sends handshake
|
||
const init_result = try initiatorHandshake(allocator, responder, initiator_ephemeral);
|
||
|
||
// Responder processes handshake
|
||
const responder_key = try responderHandshake(allocator, responder, init_result.message);
|
||
|
||
// Keys must match
|
||
try std.testing.expectEqual(init_result.root_key, responder_key);
|
||
}
|
||
|
||
test "PrekeyBundle serialization" {
|
||
const allocator = std.testing.allocator;
|
||
|
||
var bundle: PrekeyBundle = undefined;
|
||
std.crypto.random.bytes(&bundle.identity_key);
|
||
std.crypto.random.bytes(&bundle.signed_prekey_x25519);
|
||
std.crypto.random.bytes(&bundle.signed_prekey_signature);
|
||
std.crypto.random.bytes(&bundle.signed_prekey_mlkem);
|
||
std.crypto.random.bytes(&bundle.one_time_prekey_x25519);
|
||
std.crypto.random.bytes(&bundle.one_time_prekey_mlkem);
|
||
|
||
const bytes = try bundle.toBytes(allocator);
|
||
defer allocator.free(bytes);
|
||
|
||
const restored = try PrekeyBundle.fromBytes(allocator, bytes);
|
||
|
||
try std.testing.expectEqual(bundle.identity_key, restored.identity_key);
|
||
try std.testing.expectEqual(bundle.signed_prekey_x25519, restored.signed_prekey_x25519);
|
||
try std.testing.expectEqual(bundle.signed_prekey_signature, restored.signed_prekey_signature);
|
||
try std.testing.expectEqual(bundle.signed_prekey_mlkem, restored.signed_prekey_mlkem);
|
||
try std.testing.expectEqual(bundle.one_time_prekey_x25519, restored.one_time_prekey_x25519);
|
||
try std.testing.expectEqual(bundle.one_time_prekey_mlkem, restored.one_time_prekey_mlkem);
|
||
}
|