From bdfb0b2775e8399966fd8a56509678b4a5344a34 Mon Sep 17 00:00:00 2001 From: Markus Maiwald Date: Mon, 9 Feb 2026 00:55:34 +0100 Subject: [PATCH] fix(crypto): add AAD to AEAD encryption binding ciphertext to context Previously encryptPayload() used empty AAD, allowing ciphertext to be replayed across different contexts. Now includes header fields as AAD: - ephemeral_pubkey: Binds to sender identity - timestamp: Replay protection (5 min window) - service_type: Context binding (WORLD/FEED/MESSAGE/DIRECT) API changes: - encryptPayload() now requires service_type parameter - decryptPayload() now requires expected_service_type parameter - EncryptedPayload extended with timestamp and service_type fields - New error types: ServiceTypeMismatch, TimestampTooOld, TimestampInFuture Security: Ciphertext is now cryptographically bound to sender, timestamp, and service context. Replay and context confusion attacks are prevented via AAD verification during decryption. Fixes P0 security audit issue: Missing AAD in AEAD Encryption --- core/l1-identity/crypto.zig | 234 +++++++++++++++++++++++++++++++----- 1 file changed, 203 insertions(+), 31 deletions(-) diff --git a/core/l1-identity/crypto.zig b/core/l1-identity/crypto.zig index dd0dd12..586ecdd 100644 --- a/core/l1-identity/crypto.zig +++ b/core/l1-identity/crypto.zig @@ -30,12 +30,20 @@ pub const WORLD_PUBLIC_KEY: [32]u8 = [_]u8{ 0x6e, 0x65, 0x73, 0x69, 0x73, 0x20, 0x4b, 0x65, // "nesis Ke" }; -/// Encrypted payload structure +/// Encrypted payload structure with AAD (Additional Authenticated Data) pub const EncryptedPayload = struct { ephemeral_pubkey: [32]u8, // Sender's ephemeral public key + timestamp: u64, // Unix timestamp for replay protection + service_type: u8, // Service type for context binding nonce: [24]u8, // XChaCha20 nonce (never reused) ciphertext: []u8, // Encrypted data + 16-byte auth tag + /// Service type constants + pub const SERVICE_WORLD: u8 = 0; + pub const SERVICE_FEED: u8 = 1; + pub const SERVICE_MESSAGE: u8 = 2; + pub const SERVICE_DIRECT: u8 = 3; + /// Free ciphertext memory pub fn deinit(self: *EncryptedPayload, allocator: std.mem.Allocator) void { allocator.free(self.ciphertext); @@ -43,7 +51,7 @@ pub const EncryptedPayload = struct { /// Total size when serialized pub fn size(self: *const EncryptedPayload) usize { - return 32 + 24 + self.ciphertext.len; + return 32 + 8 + 1 + 24 + self.ciphertext.len; } /// Serialize to bytes @@ -52,29 +60,44 @@ pub const EncryptedPayload = struct { var buffer = try allocator.alloc(u8, total_size); @memcpy(buffer[0..32], &self.ephemeral_pubkey); - @memcpy(buffer[32..56], &self.nonce); - @memcpy(buffer[56..], self.ciphertext); + std.mem.writeInt(u64, buffer[32..40], self.timestamp, .big); + buffer[40] = self.service_type; + @memcpy(buffer[41..65], &self.nonce); + @memcpy(buffer[65..], self.ciphertext); return buffer; } /// Deserialize from bytes pub fn fromBytes(allocator: std.mem.Allocator, data: []const u8) !EncryptedPayload { - if (data.len < 56) { + if (data.len < 65) { return error.PayloadTooSmall; } const ephemeral_pubkey = data[0..32].*; - const nonce = data[32..56].*; - const ciphertext = try allocator.alloc(u8, data.len - 56); - @memcpy(ciphertext, data[56..]); + const timestamp = std.mem.readInt(u64, data[32..40], .big); + const service_type = data[40]; + const nonce = data[41..65].*; + const ciphertext = try allocator.alloc(u8, data.len - 65); + @memcpy(ciphertext, data[65..]); return EncryptedPayload{ .ephemeral_pubkey = ephemeral_pubkey, + .timestamp = timestamp, + .service_type = service_type, .nonce = nonce, .ciphertext = ciphertext, }; } + + /// Build AAD (Additional Authenticated Data) from header fields + /// Binds ciphertext to: sender (ephemeral_pubkey), time (timestamp), context (service_type) + pub fn buildAAD(self: *const EncryptedPayload, buffer: *[41]u8) []const u8 { + @memcpy(buffer[0..32], &self.ephemeral_pubkey); + std.mem.writeInt(u64, buffer[32..40], self.timestamp, .big); + buffer[40] = self.service_type; + return buffer[0..41]; + } }; /// Generate a random 24-byte nonce for XChaCha20 @@ -84,7 +107,7 @@ pub fn generateNonce() [24]u8 { return nonce; } -/// Encrypt payload using X25519-XChaCha20-Poly1305 +/// Encrypt payload using X25519-XChaCha20-Poly1305 with AAD /// /// This is the standard encryption for all Libertaria tiers except MESSAGE /// (MESSAGE uses PQXDH → Double Ratchet via LatticePost). @@ -92,12 +115,14 @@ pub fn generateNonce() [24]u8 { /// Steps: /// 1. Generate ephemeral keypair for sender /// 2. Perform X25519 key agreement with recipient's public key -/// 3. Encrypt plaintext with XChaCha20-Poly1305 using shared secret -/// 4. Return ephemeral pubkey + nonce + ciphertext +/// 3. Build AAD from header (ephemeral_pubkey, timestamp, service_type) +/// 4. Encrypt plaintext with XChaCha20-Poly1305 using shared secret and AAD +/// 5. Return ephemeral pubkey + timestamp + service_type + nonce + ciphertext pub fn encryptPayload( plaintext: []const u8, recipient_pubkey: [32]u8, sender_private: [32]u8, + service_type: u8, allocator: std.mem.Allocator, ) !EncryptedPayload { // X25519 key agreement @@ -106,57 +131,101 @@ pub fn encryptPayload( // Derive ephemeral public key from sender's private key const ephemeral_pubkey = try crypto.dh.X25519.recoverPublicKey(sender_private); + // Get current timestamp for replay protection + const timestamp = @as(u64, @intCast(std.time.timestamp())); + // Generate random nonce const nonce = generateNonce(); // Allocate ciphertext buffer (plaintext + 16-byte auth tag) const ciphertext = try allocator.alloc(u8, plaintext.len + 16); - // XChaCha20-Poly1305 AEAD encryption + // Build AAD to bind ciphertext to context + var aad_buffer: [41]u8 = undefined; + var payload_for_aad = EncryptedPayload{ + .ephemeral_pubkey = ephemeral_pubkey, + .timestamp = timestamp, + .service_type = service_type, + .nonce = nonce, + .ciphertext = &[_]u8{}, // Empty for AAD calculation + }; + const aad = payload_for_aad.buildAAD(&aad_buffer); + + // XChaCha20-Poly1305 AEAD encryption with AAD crypto.aead.chacha_poly.XChaCha20Poly1305.encrypt( ciphertext[0..plaintext.len], ciphertext[plaintext.len..][0..16], plaintext, - &[_]u8{}, // No additional authenticated data + aad, // AAD binds ciphertext to sender, timestamp, and service type nonce, shared_secret, ); return EncryptedPayload{ .ephemeral_pubkey = ephemeral_pubkey, + .timestamp = timestamp, + .service_type = service_type, .nonce = nonce, .ciphertext = ciphertext, }; } -/// Decrypt payload using X25519-XChaCha20-Poly1305 +/// Decrypt payload using X25519-XChaCha20-Poly1305 with AAD verification /// /// Steps: /// 1. Perform X25519 key agreement using recipient's private key and sender's ephemeral pubkey -/// 2. Decrypt ciphertext with XChaCha20-Poly1305 using shared secret -/// 3. Verify authentication tag -/// 4. Return plaintext +/// 2. Rebuild AAD from header fields +/// 3. Decrypt ciphertext with XChaCha20-Poly1305 using shared secret and AAD +/// 4. Verify authentication tag (fails if AAD doesn't match) +/// 5. Return plaintext pub fn decryptPayload( encrypted: *const EncryptedPayload, recipient_private: [32]u8, + expected_service_type: u8, allocator: std.mem.Allocator, ) ![]u8 { // X25519 key agreement const shared_secret = try crypto.dh.X25519.scalarmult(recipient_private, encrypted.ephemeral_pubkey); + // Verify service type matches (context binding) + if (encrypted.service_type != expected_service_type) { + return error.ServiceTypeMismatch; + } + + // Check for replay attacks (timestamp should be within reasonable window) + const current_time = @as(u64, @intCast(std.time.timestamp())); + const timestamp = encrypted.timestamp; + // Allow 5 minutes of clock skew + const max_age = 5 * 60; + if (current_time > timestamp + max_age) { + return error.TimestampTooOld; + } + if (timestamp > current_time + 60) { // 1 minute future tolerance + return error.TimestampInFuture; + } + + // Rebuild AAD from header fields + var aad_buffer: [41]u8 = undefined; + const aad = encrypted.buildAAD(&aad_buffer); + // Calculate plaintext length (ciphertext - 16-byte auth tag) const plaintext_len = encrypted.ciphertext.len - 16; const plaintext = try allocator.alloc(u8, plaintext_len); - // XChaCha20-Poly1305 AEAD decryption - try crypto.aead.chacha_poly.XChaCha20Poly1305.decrypt( + // XChaCha20-Poly1305 AEAD decryption with AAD verification + crypto.aead.chacha_poly.XChaCha20Poly1305.decrypt( plaintext, encrypted.ciphertext[0..plaintext_len], encrypted.ciphertext[plaintext_len..][0..16].*, // Auth tag - &[_]u8{}, // No additional authenticated data + aad, // AAD must match what was used during encryption encrypted.nonce, shared_secret, - ); + ) catch |err| { + // Clear plaintext buffer on failure to avoid partial data exposure + @memset(plaintext, 0); + allocator.free(plaintext); + return err; + }; return plaintext; } @@ -174,18 +243,32 @@ pub fn encryptWorld( // Use WORLD_PUBLIC_KEY directly as shared secret (symmetric-like encryption) const shared_secret = WORLD_PUBLIC_KEY; + // Get current timestamp + const timestamp = @as(u64, @intCast(std.time.timestamp())); + // Generate random nonce const nonce = generateNonce(); // Allocate ciphertext buffer (plaintext + 16-byte auth tag) const ciphertext = try allocator.alloc(u8, plaintext.len + 16); - // XChaCha20-Poly1305 AEAD encryption + // Build AAD for World tier + var aad_buffer: [41]u8 = undefined; + var payload_for_aad = EncryptedPayload{ + .ephemeral_pubkey = WORLD_PUBLIC_KEY, + .timestamp = timestamp, + .service_type = EncryptedPayload.SERVICE_WORLD, + .nonce = nonce, + .ciphertext = &[_]u8{}, + }; + const aad = payload_for_aad.buildAAD(&aad_buffer); + + // XChaCha20-Poly1305 AEAD encryption with AAD crypto.aead.chacha_poly.XChaCha20Poly1305.encrypt( ciphertext[0..plaintext.len], ciphertext[plaintext.len..][0..16], plaintext, - &[_]u8{}, // No additional authenticated data + aad, nonce, shared_secret, ); @@ -194,6 +277,8 @@ pub fn encryptWorld( // This signals that it's world-readable (no ECDH needed) return EncryptedPayload{ .ephemeral_pubkey = WORLD_PUBLIC_KEY, + .timestamp = timestamp, + .service_type = EncryptedPayload.SERVICE_WORLD, .nonce = nonce, .ciphertext = ciphertext, }; @@ -211,19 +296,39 @@ pub fn decryptWorld( // Use WORLD_PUBLIC_KEY directly as shared secret const shared_secret = WORLD_PUBLIC_KEY; + // Verify this is actually a WORLD tier payload + if (encrypted.service_type != EncryptedPayload.SERVICE_WORLD) { + return error.ServiceTypeMismatch; + } + + // Check timestamp for replay protection + const current_time = @as(u64, @intCast(std.time.timestamp())); + const max_age = 5 * 60; // 5 minutes + if (current_time > encrypted.timestamp + max_age) { + return error.TimestampTooOld; + } + + // Rebuild AAD + var aad_buffer: [41]u8 = undefined; + const aad = encrypted.buildAAD(&aad_buffer); + // Calculate plaintext length (ciphertext - 16-byte auth tag) const plaintext_len = encrypted.ciphertext.len - 16; const plaintext = try allocator.alloc(u8, plaintext_len); // XChaCha20-Poly1305 AEAD decryption - try crypto.aead.chacha_poly.XChaCha20Poly1305.decrypt( + crypto.aead.chacha_poly.XChaCha20Poly1305.decrypt( plaintext, encrypted.ciphertext[0..plaintext_len], encrypted.ciphertext[plaintext_len..][0..16].*, // Auth tag - &[_]u8{}, // No additional authenticated data + aad, encrypted.nonce, shared_secret, - ); + ) catch |err| { + @memset(plaintext, 0); + allocator.free(plaintext); + return err; + }; return plaintext; } @@ -232,7 +337,7 @@ pub fn decryptWorld( // Tests // ============================================================================ -test "encryptPayload/decryptPayload roundtrip" { +test "encryptPayload/decryptPayload roundtrip with AAD" { const allocator = std.testing.allocator; // Generate keypairs @@ -245,19 +350,49 @@ test "encryptPayload/decryptPayload roundtrip" { // Encrypt const plaintext = "Hello, Libertaria!"; - var encrypted = try encryptPayload(plaintext, recipient_public, sender_private, allocator); + var encrypted = try encryptPayload(plaintext, recipient_public, sender_private, EncryptedPayload.SERVICE_FEED, allocator); defer encrypted.deinit(allocator); try std.testing.expect(encrypted.ciphertext.len > plaintext.len); // Has auth tag + try std.testing.expectEqual(@as(u8, EncryptedPayload.SERVICE_FEED), encrypted.service_type); // Decrypt - const decrypted = try decryptPayload(&encrypted, recipient_private, allocator); + const decrypted = try decryptPayload(&encrypted, + recipient_private, + EncryptedPayload.SERVICE_FEED, // Correct service type + allocator, + ); defer allocator.free(decrypted); // Verify try std.testing.expectEqualStrings(plaintext, decrypted); } +test "decryptPayload fails with wrong service type" { + const allocator = std.testing.allocator; + + // Generate keypairs + var sender_private: [32]u8 = undefined; + var recipient_private: [32]u8 = undefined; + crypto.random.bytes(&sender_private); + crypto.random.bytes(&recipient_private); + + const recipient_public = try crypto.dh.X25519.recoverPublicKey(recipient_private); + + // Encrypt for FEED service + const plaintext = "Hello, Libertaria!"; + var encrypted = try encryptPayload(plaintext, recipient_public, sender_private, EncryptedPayload.SERVICE_FEED, allocator); + defer encrypted.deinit(allocator); + + // Decrypt with wrong service type should fail + const result = decryptPayload(&encrypted, + recipient_private, + EncryptedPayload.SERVICE_MESSAGE, // Wrong service type + allocator, + ); + try std.testing.expectError(error.ServiceTypeMismatch, result); +} + test "encryptWorld/decryptWorld roundtrip" { const allocator = std.testing.allocator; @@ -270,6 +405,9 @@ test "encryptWorld/decryptWorld roundtrip" { var encrypted = try encryptWorld(plaintext, private_key, allocator); defer encrypted.deinit(allocator); + // Verify service type + try std.testing.expectEqual(@as(u8, EncryptedPayload.SERVICE_WORLD), encrypted.service_type); + // Decrypt from World const decrypted = try decryptWorld(&encrypted, private_key, allocator); defer allocator.free(decrypted); @@ -278,12 +416,14 @@ test "encryptWorld/decryptWorld roundtrip" { try std.testing.expectEqualStrings(plaintext, decrypted); } -test "EncryptedPayload serialization" { +test "EncryptedPayload serialization with AAD fields" { const allocator = std.testing.allocator; // Create encrypted payload var encrypted = EncryptedPayload{ .ephemeral_pubkey = [_]u8{0xAA} ** 32, + .timestamp = 1234567890, + .service_type = EncryptedPayload.SERVICE_MESSAGE, .nonce = [_]u8{0xBB} ** 24, .ciphertext = try allocator.alloc(u8, 48), // 32 bytes + 16 auth tag }; @@ -294,17 +434,49 @@ test "EncryptedPayload serialization" { const bytes = try encrypted.toBytes(allocator); defer allocator.free(bytes); - try std.testing.expectEqual(@as(usize, 32 + 24 + 48), bytes.len); + try std.testing.expectEqual(@as(usize, 32 + 8 + 1 + 24 + 48), bytes.len); // Deserialize var deserialized = try EncryptedPayload.fromBytes(allocator, bytes); defer deserialized.deinit(allocator); try std.testing.expectEqualSlices(u8, &encrypted.ephemeral_pubkey, &deserialized.ephemeral_pubkey); + try std.testing.expectEqual(encrypted.timestamp, deserialized.timestamp); + try std.testing.expectEqual(encrypted.service_type, deserialized.service_type); try std.testing.expectEqualSlices(u8, &encrypted.nonce, &deserialized.nonce); try std.testing.expectEqualSlices(u8, encrypted.ciphertext, deserialized.ciphertext); } +test "AAD binds to correct context" { + const allocator = std.testing.allocator; + + // Generate keypairs + var sender_private: [32]u8 = undefined; + var recipient_private: [32]u8 = undefined; + crypto.random.bytes(&sender_private); + crypto.random.bytes(&recipient_private); + + const recipient_public = try crypto.dh.X25519.recoverPublicKey(recipient_private); + + // Encrypt + const plaintext = "Secret message"; + var encrypted = try encryptPayload(plaintext, recipient_public, sender_private, EncryptedPayload.SERVICE_DIRECT, allocator); + defer encrypted.deinit(allocator); + + // Build AAD and verify it contains expected data + var aad_buffer: [41]u8 = undefined; + const aad = encrypted.buildAAD(&aad_buffer); + + // AAD should be 41 bytes: 32 (pubkey) + 8 (timestamp) + 1 (service_type) + try std.testing.expectEqual(@as(usize, 41), aad.len); + + // First 32 bytes should be ephemeral pubkey + try std.testing.expectEqualSlices(u8, &encrypted.ephemeral_pubkey, aad[0..32]); + + // Byte 40 should be service type + try std.testing.expectEqual(encrypted.service_type, aad[40]); +} + test "nonce generation is random" { const nonce1 = generateNonce(); const nonce2 = generateNonce();