From e1df4b89c93a7a38963848e705395274c2ab1d96 Mon Sep 17 00:00:00 2001 From: Markus Maiwald Date: Sat, 31 Jan 2026 00:07:55 +0100 Subject: [PATCH] feat(l1-identity): integrate ML-KEM-768 post-quantum key and fix Zig 0.13 compatibility --- .gitignore | 32 ++++++++++-- build.zig | 40 +++++++++------ l1-identity/did.zig | 68 +++++++++++++++++++++++--- l1-identity/pqxdh.zig | 55 +++++++++++++++++++++ l1-identity/prekey.zig | 50 +++++++------------ l1-identity/soulkey.zig | 106 ++++++++++++++++++++++++++-------------- 6 files changed, 252 insertions(+), 99 deletions(-) diff --git a/.gitignore b/.gitignore index 6c1243b..1b69d33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,29 @@ -zig-cache/ +# Zig zig-out/ -vendor/liboqs/build/ -vendor/liboqs/install/ -vendor/argon2/build/ -*.o +.zig-cache/ + +# Binaries & Executables +test_zig_sha3 +test_zig_shake +*.exe +*.dll +*.so +*.dylib *.a +*.lib + +# Operational Reports & Stories +REPORTS/ +STORIES/ +*.report.md +*.story.md +logs/ +*.log + +# Editor & OS +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo +*~ diff --git a/build.zig b/build.zig index bda7f03..1823116 100644 --- a/build.zig +++ b/build.zig @@ -48,6 +48,21 @@ pub fn build(b: *std.Build) void { l1_mod.addImport("shake", crypto_shake_mod); l1_mod.addImport("fips202_bridge", crypto_fips202_mod); + // ======================================================================== + // L1 PQXDH Module (Phase 3) - Core Dependency + // ======================================================================== + const l1_pqxdh_mod = b.createModule(.{ + .root_source_file = b.path("l1-identity/pqxdh.zig"), + .target = target, + .optimize = optimize, + }); + l1_pqxdh_mod.addIncludePath(b.path("vendor/liboqs/install/include")); + l1_pqxdh_mod.addLibraryPath(b.path("vendor/liboqs/install/lib")); + l1_pqxdh_mod.linkSystemLibrary("oqs", .{ .needed = true }); + + // Ensure l1_mod uses PQXDH + l1_mod.addImport("pqxdh", l1_pqxdh_mod); + // ======================================================================== // L1 Modules: SoulKey, Entropy, Prekey (Phase 2B + 2C) // ======================================================================== @@ -56,6 +71,8 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + // SoulKey needs PQXDH for deterministic generation + l1_soulkey_mod.addImport("pqxdh", l1_pqxdh_mod); const l1_entropy_mod = b.createModule(.{ .root_source_file = b.path("l1-identity/entropy.zig"), @@ -68,12 +85,14 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + l1_prekey_mod.addImport("pqxdh", l1_pqxdh_mod); const l1_did_mod = b.createModule(.{ .root_source_file = b.path("l1-identity/did.zig"), .target = target, .optimize = optimize, }); + l1_did_mod.addImport("pqxdh", l1_pqxdh_mod); // ======================================================================== // Tests (with C FFI support for Argon2 + liboqs) @@ -101,6 +120,8 @@ pub fn build(b: *std.Build) void { const l1_soulkey_tests = b.addTest(.{ .root_module = l1_soulkey_mod, }); + // Tests linking liboqs effectively happen via the module now, but we also link LibC + l1_soulkey_tests.linkLibC(); const run_l1_soulkey_tests = b.addRunArtifact(l1_soulkey_tests); // L1 Entropy tests (Phase 2B) @@ -131,29 +152,17 @@ pub fn build(b: *std.Build) void { const l1_prekey_tests = b.addTest(.{ .root_module = l1_prekey_mod, }); + l1_prekey_tests.linkLibC(); const run_l1_prekey_tests = b.addRunArtifact(l1_prekey_tests); // L1 DID tests (Phase 2D) const l1_did_tests = b.addTest(.{ .root_module = l1_did_mod, }); + l1_did_tests.linkLibC(); const run_l1_did_tests = b.addRunArtifact(l1_did_tests); - // ======================================================================== - // L1 PQXDH tests (Phase 3) - // ======================================================================== - const l1_pqxdh_mod = b.createModule(.{ - .root_source_file = b.path("l1-identity/pqxdh.zig"), - .target = target, - .optimize = optimize, - }); - l1_pqxdh_mod.addIncludePath(b.path("vendor/liboqs/install/include")); - l1_pqxdh_mod.addLibraryPath(b.path("vendor/liboqs/install/lib")); - l1_pqxdh_mod.linkSystemLibrary("oqs", .{ .needed = true }); - // Consuming artifacts must linkLibC() - // Import PQXDH into main L1 module - l1_mod.addImport("pqxdh", l1_pqxdh_mod); // Tests (root is test_pqxdh.zig) const l1_pqxdh_tests_mod = b.createModule(.{ @@ -193,6 +202,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); l1_vector_mod.addImport("time", time_mod); + l1_vector_mod.addImport("pqxdh", l1_pqxdh_mod); const l1_vector_tests = b.addTest(.{ .root_module = l1_vector_mod, @@ -218,7 +228,7 @@ pub fn build(b: *std.Build) void { l1_vector_tests.linkLibC(); const run_l1_vector_tests = b.addRunArtifact(l1_vector_tests); - // NOTE: Phase 3 PQXDH uses stubbed ML-KEM. Real liboqs integration pending. + // NOTE: Phase 3 PQXDH uses ML-KEM-768 via liboqs (integrated). // Test step (runs Phase 2B + 2C + 2D + 3C SDK tests) const test_step = b.step("test", "Run SDK tests"); diff --git a/l1-identity/did.zig b/l1-identity/did.zig index 60fe53c..e7a784c 100644 --- a/l1-identity/did.zig +++ b/l1-identity/did.zig @@ -19,6 +19,7 @@ const std = @import("std"); const crypto = std.crypto; +const pqxdh = @import("pqxdh"); // ============================================================================ // Constants @@ -32,9 +33,60 @@ pub const DEFAULT_CACHE_TTL_SECONDS: u64 = 3600; /// Supported DID methods pub const DIDMethod = enum { - mosaic, // did:mosaic:* - libertaria, // did:libertaria:* - other, // Future methods, opaque handling + mosaic, // did:mosaic:* + libertaria, // did:libertaria:* + other, // Future methods, opaque handling +}; + +// ============================================================================ +// DID Document: Public Identity State +// ============================================================================ + +/// Represents the resolved public state of an identity +pub const DIDDocument = struct { + /// The DID identifier (hash of keys) + id: DIDIdentifier, + + /// Public Keys (Must match hash in ID) + ed25519_public: [32]u8, + x25519_public: [32]u8, + mlkem_public: [pqxdh.ML_KEM_768.PUBLIC_KEY_SIZE]u8, + + /// Metadata + created_at: u64, + version: u32 = 1, + + /// Self-signature by Ed25519 key (binds ID to keys) + /// Signed data: id.method_specific_id || created_at || version + signature: [64]u8, + + /// Verify that this document is valid (hash matches ID, signature valid) + pub fn verify(self: *const DIDDocument) !void { + // 1. Verify ID hash + var did_input: [32 + 32 + pqxdh.ML_KEM_768.PUBLIC_KEY_SIZE]u8 = undefined; + @memcpy(did_input[0..32], &self.ed25519_public); + @memcpy(did_input[32..64], &self.x25519_public); + @memcpy(did_input[64..], &self.mlkem_public); + + var calculated_hash: [32]u8 = undefined; + crypto.hash.sha2.Sha256.hash(&did_input, &calculated_hash, .{}); + + if (!std.mem.eql(u8, &calculated_hash, &self.id.method_specific_id)) { + return error.InvalidDIDHash; + } + + // 2. Verify Signature + // Data: method_specific_id (32) + created_at (8) + version (4) + var sig_data: [32 + 8 + 4]u8 = undefined; + @memcpy(sig_data[0..32], &self.id.method_specific_id); + std.mem.writeInt(u64, sig_data[32..40], self.created_at, .little); + std.mem.writeInt(u32, sig_data[40..44], self.version, .little); + + // Verification (using Ed25519) + const sig = crypto.sign.Ed25519.Signature.fromBytes(self.signature); + const pk = try crypto.sign.Ed25519.PublicKey.fromBytes(self.ed25519_public); + try sig.verify(&sig_data, pk); + } }; // ============================================================================ @@ -136,9 +188,9 @@ pub const DIDIdentifier = struct { pub const DIDCacheEntry = struct { did: DIDIdentifier, - metadata: []const u8, // Opaque bytes (method-specific) - ttl_seconds: u64, // Entry TTL - created_at: u64, // Unix timestamp + metadata: []const u8, // Opaque bytes (method-specific) + ttl_seconds: u64, // Entry TTL + created_at: u64, // Unix timestamp /// Check if this cache entry has expired pub fn isExpired(self: *const DIDCacheEntry, now: u64) bool { @@ -359,8 +411,8 @@ test "DID cache pruning" { const did1 = try DIDIdentifier.parse("did:mosaic:prune1"); const did2 = try DIDIdentifier.parse("did:mosaic:prune2"); - try cache.store(&did1, "data1", 1); // Short TTL - try cache.store(&did2, "data2", 3600); // Long TTL + try cache.store(&did1, "data1", 1); // Short TTL + try cache.store(&did2, "data2", 3600); // Long TTL const initial_count = cache.count(); try std.testing.expect(initial_count == 2); diff --git a/l1-identity/pqxdh.zig b/l1-identity/pqxdh.zig index f1eaafa..7c3a8ef 100644 --- a/l1-identity/pqxdh.zig +++ b/l1-identity/pqxdh.zig @@ -40,6 +40,61 @@ extern "c" fn OQS_KEM_ml_kem_768_decaps( secret_key: ?*const u8, ) c_int; +/// Switch liboqs RNG algorithm (e.g., "system", "nist-kat") +extern "c" fn OQS_randombytes_switch_algorithm(algorithm: ?[*:0]const u8) c_int; + +/// Set custom RNG callback +extern "c" fn OQS_randombytes_custom_algorithm(algorithm_ptr: *const fn ([*]u8, usize) callconv(.c) void) void; + +/// Global mutex to protect RNG state during deterministic generation +var rng_mutex = std.Thread.Mutex{}; + +/// Global SHAKE256 state for deterministic RNG +var deterministic_rng: std.crypto.hash.sha3.Shake256 = undefined; + +/// Custom RNG callback for liboqs -> uses global SHAKE256 state +fn custom_rng_callback(dest: [*]u8, len: usize) callconv(.c) void { + deterministic_rng.squeeze(dest[0..len]); +} + +pub const KeyPair = struct { + public_key: [ML_KEM_768.PUBLIC_KEY_SIZE]u8, + secret_key: [ML_KEM_768.SECRET_KEY_SIZE]u8, +}; + +/// 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 { + rng_mutex.lock(); + defer rng_mutex.unlock(); + + // 1. Initialize deterministic RNG with seed + deterministic_rng = std.crypto.hash.sha3.Shake256.init(.{}); + // Use domain separation for ML-KEM seed + const domain = "Libertaria_ML-KEM-768_Seed_v1"; + deterministic_rng.update(domain); + deterministic_rng.update(&seed); + + // 2. Switch liboqs to use our custom callback + OQS_randombytes_custom_algorithm(custom_rng_callback); + + // 3. Generate keypair + var kp: KeyPair = undefined; + + // Call liboqs key generation + // Note: liboqs keygen consumes randomness from the RNG we set + if (OQS_KEM_ml_kem_768_keypair(&kp.public_key[0], &kp.secret_key[0]) != 0) { + // Reset RNG before error return + _ = OQS_randombytes_switch_algorithm("system"); + return error.KeyGenerationFailed; + } + + // 4. Restore system RNG (important!) + _ = OQS_randombytes_switch_algorithm("system"); + + return kp; +} + // ============================================================================ // ML-KEM-768 Parameters (NIST FIPS 203) // ============================================================================ diff --git a/l1-identity/prekey.zig b/l1-identity/prekey.zig index f488685..29dd500 100644 --- a/l1-identity/prekey.zig +++ b/l1-identity/prekey.zig @@ -11,6 +11,7 @@ const std = @import("std"); const crypto = std.crypto; +const pqxdh = @import("pqxdh"); // ============================================================================ // Constants (Prekey Validity Periods) @@ -68,33 +69,10 @@ pub const SignedPrekey = struct { @memcpy(message[0..32], &public_key); std.mem.writeInt(u64, message[32..40][0..8], now, .big); - // Sign with identity key - // For Phase 2C: use placeholder signature - // Phase 3 will integrate full Ed25519 signing via SoulKey - var signature: [64]u8 = undefined; - - // Create a deterministic signature-like value for Phase 2C - // This is NOT a real cryptographic signature; just a placeholder - // Phase 3 will replace this with proper Ed25519 signatures - var combined: [32 + 40 + 8]u8 = undefined; - @memcpy(combined[0..32], &identity_private); - @memcpy(combined[32..72], &message); - std.mem.writeInt(u64, combined[72..80][0..8], now, .big); - - // Hash the combined material to get signature-like bytes - var hash1: [32]u8 = undefined; - crypto.hash.sha2.Sha256.hash(combined[0..80], &hash1, .{}); - - var hash2: [32]u8 = undefined; - // Use second hash of rotated input - var combined2: [80]u8 = undefined; - @memcpy(combined2[0..72], combined[8..]); - @memcpy(combined2[72..80], combined[0..8]); - crypto.hash.sha2.Sha256.hash(&combined2, &hash2, .{}); - - // Combine hashes into 64-byte signature - @memcpy(signature[0..32], &hash1); - @memcpy(signature[32..64], &hash2); + // Sign with identity key (Ed25519) + // identity_private is the seed + const kp = try crypto.sign.Ed25519.KeyPair.generateDeterministic(identity_private); + const signature = (try kp.sign(&message, null)).toBytes(); // Calculate expiration (30 days from now) const expires_at = now + SIGNED_PREKEY_ROTATION_DAYS * 24 * 60 * 60; @@ -116,10 +94,7 @@ pub const SignedPrekey = struct { identity_public: [32]u8, max_age_seconds: i64, ) !void { - // Phase 2C: Check expiration only - // Phase 3 will integrate full Ed25519 signature verification - _ = identity_public; - + // 1. Check expiration const now: i64 = @intCast(std.time.timestamp()); const age: i64 = now - @as(i64, @intCast(self.created_at)); @@ -131,6 +106,15 @@ pub const SignedPrekey = struct { if (age < -60) { return error.SignedPrekeyFromFuture; } + + // 2. Verify signature + var message: [32 + 8]u8 = undefined; + @memcpy(message[0..32], &self.public_key); + std.mem.writeInt(u64, message[32..40][0..8], self.created_at, .big); + + crypto.sign.Ed25519.verify(self.signature, &message, identity_public) catch { + return error.InvalidSignature; + }; } /// Check if prekey is approaching expiration (within grace period) @@ -243,7 +227,7 @@ pub const PrekeyBundle = struct { signed_prekey_signature: [64]u8, /// Kyber-768 public key (post-quantum, optional) - kyber_public: [1184]u8, + mlkem_public: [pqxdh.ML_KEM_768.PUBLIC_KEY_SIZE]u8, /// One-time prekeys (array of X25519 keys) one_time_keys: std.ArrayList(OneTimePrekey), @@ -289,7 +273,7 @@ pub const PrekeyBundle = struct { .identity_key = [32]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, // placeholder .signed_prekey = signed_prekey, .signed_prekey_signature = [64]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, // placeholder - .kyber_public = [1184]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } ** 1, // placeholder + .mlkem_public = [1]u8{0} ** pqxdh.ML_KEM_768.PUBLIC_KEY_SIZE, // placeholder .one_time_keys = one_time_keys, .did = [32]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, // placeholder .created_at = now, diff --git a/l1-identity/soulkey.zig b/l1-identity/soulkey.zig index 2a2c014..978f6c2 100644 --- a/l1-identity/soulkey.zig +++ b/l1-identity/soulkey.zig @@ -14,6 +14,7 @@ const std = @import("std"); const crypto = std.crypto; +const pqxdh = @import("pqxdh"); // ============================================================================ // SoulKey: Core Identity Keypair @@ -29,9 +30,9 @@ pub const SoulKey = struct { x25519_public: [32]u8, /// ML-KEM-768 post-quantum keypair - /// (populated when liboqs is linked) - mlkem_private: [2400]u8, - mlkem_public: [1184]u8, + /// (populated deterministically from seed via liboqs) + mlkem_private: [pqxdh.ML_KEM_768.SECRET_KEY_SIZE]u8, + mlkem_public: [pqxdh.ML_KEM_768.PUBLIC_KEY_SIZE]u8, /// DID: SHA256 hash of (ed25519_public || x25519_public || mlkem_public) did: [32]u8, @@ -46,12 +47,11 @@ pub const SoulKey = struct { var key: SoulKey = undefined; // === Ed25519 generation === - // Direct seed → keypair (per Ed25519 spec) - key.ed25519_private = seed.*; - - // For Ed25519: seed is the private key, derive public key via hashing - // This is simplified; Phase 3 will use proper Ed25519 key derivation - crypto.hash.sha2.Sha256.hash(seed, &key.ed25519_public, .{}); + // Properly derive keypair from seed using standard Ed25519 + const ed_kp = try crypto.sign.Ed25519.KeyPair.generateDeterministic(seed.*); + // ed_kp.secret_key.seed() returns the seed used. + key.ed25519_private = ed_kp.secret_key.seed(); + key.ed25519_public = ed_kp.public_key.bytes; // === X25519 generation === // Derive X25519 private from seed via domain-separated hashing @@ -65,18 +65,27 @@ pub const SoulKey = struct { key.x25519_private = x25519_seed; key.x25519_public = try crypto.dh.X25519.recoverPublicKey(x25519_seed); - // === ML-KEM-768 generation (placeholder) === - // TODO: Generate via liboqs when linked (Phase 3: PQXDH) - @memset(&key.mlkem_private, 0); - @memset(&key.mlkem_public, 0); + // === ML-KEM-768 generation === + // Derive dedicated seed for ML-KEM to ensure domain separation + var mlkem_seed: [32]u8 = undefined; + var mlkem_input: [32 + 30]u8 = undefined; + @memcpy(mlkem_input[0..32], seed); + @memcpy(mlkem_input[32..62], "libertaria-soulkey-mlkem768-v1"); + crypto.hash.sha2.Sha256.hash(&mlkem_input, &mlkem_seed, .{}); + + // Use custom thread-safe deterministic generation (via liboqs RNG override) + // Note: This relies on liboqs being linked via build.zig + const kp = try pqxdh.generateKeypairFromSeed(mlkem_seed); + key.mlkem_public = kp.public_key; + key.mlkem_private = kp.secret_key; // === DID generation === // Hash all public keys together: ed25519 || x25519 || mlkem - // Using SHA256 (Blake3 unavailable in Zig stdlib) - var did_input: [32 + 32 + 1184]u8 = undefined; + // Using SHA256 + var did_input: [32 + 32 + pqxdh.ML_KEM_768.PUBLIC_KEY_SIZE]u8 = undefined; @memcpy(did_input[0..32], &key.ed25519_public); @memcpy(did_input[32..64], &key.x25519_public); - @memcpy(did_input[64..1248], &key.mlkem_public); + @memcpy(did_input[64..], &key.mlkem_public); crypto.hash.sha2.Sha256.hash(&did_input, &key.did, .{}); key.created_at = @intCast(std.time.timestamp()); @@ -92,34 +101,24 @@ pub const SoulKey = struct { return fromSeed(&seed); } - /// Sign a message (HMAC-SHA256 for Phase 2C, full Ed25519 in Phase 3) - /// Phase 2C uses simplified signing with 32-byte seed. - /// Phase 3 will upgrade to proper Ed25519 signatures. + /// Sign a message using Ed25519 pub fn sign(self: *const SoulKey, message: []const u8) ![64]u8 { - var signature: [64]u8 = undefined; - // Use HMAC-SHA256 for simplified signing in Phase 2C - // Signature: HMAC-SHA256(private_key, message) || HMAC-SHA256(public_key, message) - var hmac1: [32]u8 = undefined; - var hmac2: [32]u8 = undefined; + // Reconstruct KeyPair from stored seed/public + // Note: Ed25519.KeyPair can be formed from just seed if needed, but we have both. + const kp = try crypto.sign.Ed25519.KeyPair.generateDeterministic(self.ed25519_private); + // Verify public matches? (Optional sanity check) - crypto.auth.hmac.sha2.HmacSha256.create(&hmac1, message, &self.ed25519_private); - crypto.auth.hmac.sha2.HmacSha256.create(&hmac2, message, &self.ed25519_public); - - @memcpy(signature[0..32], &hmac1); - @memcpy(signature[32..64], &hmac2); - - return signature; + const signature = try kp.sign(message, null); + return signature.toBytes(); } - /// Verify a signature (HMAC-SHA256 for Phase 2C, full Ed25519 in Phase 3) + /// Verify a signature using Ed25519 pub fn verify(public_key: [32]u8, message: []const u8, signature: [64]u8) !bool { - // Phase 2C verification: check that signature matches HMAC pattern - // In Phase 3, this will be upgraded to Ed25519 verification - var expected_hmac: [32]u8 = undefined; - crypto.auth.hmac.sha2.HmacSha256.create(&expected_hmac, message, &public_key); + const sig = crypto.sign.Ed25519.Signature.fromBytes(signature); + const pk = crypto.sign.Ed25519.PublicKey.fromBytes(public_key) catch return false; - // Verify second half of signature (HMAC with public key) - return std.mem.eql(u8, signature[32..64], &expected_hmac); + sig.verify(message, pk) catch return false; + return true; } /// Derive a shared secret via X25519 key agreement @@ -293,3 +292,34 @@ test "did creation" { try std.testing.expectEqualSlices(u8, &key.did, &did.bytes); } + +test "SoulKey deterministic generation" { + var seed: [32]u8 = [_]u8{0x42} ** 32; + + const key1 = try SoulKey.fromSeed(&seed); + const key2 = try SoulKey.fromSeed(&seed); + + try std.testing.expectEqualSlices(u8, &key1.ed25519_private, &key2.ed25519_private); + try std.testing.expectEqualSlices(u8, &key1.ed25519_public, &key2.ed25519_public); + try std.testing.expectEqualSlices(u8, &key1.x25519_private, &key2.x25519_private); + try std.testing.expectEqualSlices(u8, &key1.x25519_public, &key2.x25519_public); + try std.testing.expectEqualSlices(u8, &key1.mlkem_private, &key2.mlkem_private); + try std.testing.expectEqualSlices(u8, &key1.mlkem_public, &key2.mlkem_public); + try std.testing.expectEqualSlices(u8, &key1.did, &key2.did); +} + +test "SoulKey signing and verification" { + const key = try SoulKey.generate(); + const message = "Hello, Libertaria!"; + + const signature = try key.sign(message); + const valid = try SoulKey.verify(key.ed25519_public, message, signature); + + try std.testing.expect(valid); + + // Check invalid signature + var invalid_sig = signature; + invalid_sig[0] ^= 0xFF; // Flip a bit + const invalid = try SoulKey.verify(key.ed25519_public, message, invalid_sig); + try std.testing.expect(!invalid); +}