From 7ba9e4f5e6f1c3de7ae60aa523732872596a7329 Mon Sep 17 00:00:00 2001 From: Ksan Date: Sun, 29 Mar 2026 23:43:03 +0200 Subject: [PATCH] scenebuilder wasnt working on my laptop for some reason so i implemented the voting system i guess.... still need to test it and refactor it a little bit, i have a lot of duplicate code that i can just put into 2-3 external functions --- .idea/misc.xml | 2 +- .../ksan/voteSystem/HmacKeyRepository.java | 55 ++++++ src/main/java/dev/ksan/voteSystem/Poll.java | 96 ++++++++++ .../java/dev/ksan/voteSystem/PollOption.java | 26 +++ .../dev/ksan/voteSystem/PollRepository.java | 124 +++++++++++++ .../java/dev/ksan/voteSystem/PollStatus.java | 7 + .../java/dev/ksan/voteSystem/TallyResult.java | 83 +++++++++ .../voteSystem/TallyResultRepository.java | 29 +++ .../dev/ksan/voteSystem/TallyService.java | 169 ++++++++++++++++++ src/main/java/dev/ksan/voteSystem/Vote.java | 79 ++++++++ .../dev/ksan/voteSystem/VoteRepository.java | 116 ++++++++++++ .../dev/ksan/voteSystem/VotingService.java | 122 +++++++++++++ src/main/resources/fxml/OrganizerView.fxml | 0 src/main/resources/fxml/VoterView.fxml | 0 14 files changed, 907 insertions(+), 1 deletion(-) create mode 100644 src/main/java/dev/ksan/voteSystem/HmacKeyRepository.java create mode 100644 src/main/java/dev/ksan/voteSystem/Poll.java create mode 100644 src/main/java/dev/ksan/voteSystem/PollOption.java create mode 100644 src/main/java/dev/ksan/voteSystem/PollRepository.java create mode 100644 src/main/java/dev/ksan/voteSystem/PollStatus.java create mode 100644 src/main/java/dev/ksan/voteSystem/TallyResult.java create mode 100644 src/main/java/dev/ksan/voteSystem/TallyResultRepository.java create mode 100644 src/main/java/dev/ksan/voteSystem/TallyService.java create mode 100644 src/main/java/dev/ksan/voteSystem/Vote.java create mode 100644 src/main/java/dev/ksan/voteSystem/VoteRepository.java create mode 100644 src/main/java/dev/ksan/voteSystem/VotingService.java create mode 100644 src/main/resources/fxml/OrganizerView.fxml create mode 100644 src/main/resources/fxml/VoterView.fxml diff --git a/.idea/misc.xml b/.idea/misc.xml index 1bd18cd..8fb11b1 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -4,7 +4,7 @@ - + \ No newline at end of file diff --git a/src/main/java/dev/ksan/voteSystem/HmacKeyRepository.java b/src/main/java/dev/ksan/voteSystem/HmacKeyRepository.java new file mode 100644 index 0000000..0cf79fc --- /dev/null +++ b/src/main/java/dev/ksan/voteSystem/HmacKeyRepository.java @@ -0,0 +1,55 @@ +package dev.ksan.voteSystem; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +public class HmacKeyRepository { + + private static final Path HMAC_DIR = Path.of("data/hmackeys"); + private static final ConcurrentHashMap locks = + new ConcurrentHashMap<>(); + + private static ReentrantReadWriteLock getLock(String key) { + return locks.computeIfAbsent(key, k -> new ReentrantReadWriteLock()); + } + + public static void save(String voterId, String pollId, byte[] hmacKey) throws Exception { + Path path = resolvePath(voterId, pollId); + Files.createDirectories(path.getParent()); + + String key = lockKey(voterId, pollId); + ReentrantReadWriteLock.WriteLock writeLock = getLock(key).writeLock(); + writeLock.lock(); + try { + Files.write(path, hmacKey); + } finally { + writeLock.unlock(); + } + } + + public static byte[] load(String voterId, String pollId) throws Exception { + Path path = resolvePath(voterId, pollId); + String key = lockKey(voterId, pollId); + + ReentrantReadWriteLock.ReadLock readLock = getLock(key).readLock(); + readLock.lock(); + try { + if (!Files.exists(path)) + throw new IllegalStateException("No HMAC key found for voter: " + + voterId + " in poll: " + pollId); + return Files.readAllBytes(path); + } finally { + readLock.unlock(); + } + } + + private static Path resolvePath(String voterId, String pollId) { + return HMAC_DIR.resolve(pollId).resolve(voterId + ".key"); + } + + private static String lockKey(String voterId, String pollId) { + return pollId + ":" + voterId; + } +} \ No newline at end of file diff --git a/src/main/java/dev/ksan/voteSystem/Poll.java b/src/main/java/dev/ksan/voteSystem/Poll.java new file mode 100644 index 0000000..06d437c --- /dev/null +++ b/src/main/java/dev/ksan/voteSystem/Poll.java @@ -0,0 +1,96 @@ +package dev.ksan.voteSystem; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +public class Poll implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private final String pollId; + + private final String organizerId; + + private String title; + + private String description; + + private LocalDateTime startTime; + private LocalDateTime endTime; + + private List options; // 2-5 options + private boolean tallied; // true after organizer runs tally + + public Poll(String organizerId, String title, String description, + LocalDateTime startTime, LocalDateTime endTime) { + this.pollId = UUID.randomUUID().toString(); + this.organizerId = organizerId; + this.title = title; + this.description = description; + this.startTime = startTime; + this.endTime = endTime; + this.options = new ArrayList<>(); + this.tallied = false; + } + + public PollStatus getStatus() { + LocalDateTime now = LocalDateTime.now(); + if (now.isBefore(startTime)) + return PollStatus.PENDING; + if (now.isAfter(endTime)) + return PollStatus.FINISHED; + return PollStatus.ACTIVE; + } + + public void addOption(PollOption option) { + if (options.size() >= 5) + throw new IllegalStateException("Maximum 5 options allowed."); + options.add(option); + } + + public boolean isReadyForTally() { + return getStatus() == PollStatus.FINISHED && !tallied; + } + + public String getPollId() { + return pollId; + } + + public String getOrganizerId() { + return organizerId; + } + + public String getTitle() { + return title; + } + + public String getDescription() { + return description; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public List getOptions() { + return Collections.unmodifiableList(options); + } + + public boolean isTallied() { + return tallied; + } + + public void setTallied(boolean tallied) { + this.tallied = tallied; + } +} diff --git a/src/main/java/dev/ksan/voteSystem/PollOption.java b/src/main/java/dev/ksan/voteSystem/PollOption.java new file mode 100644 index 0000000..47bc3b2 --- /dev/null +++ b/src/main/java/dev/ksan/voteSystem/PollOption.java @@ -0,0 +1,26 @@ +package dev.ksan.voteSystem; + +import java.io.Serial; +import java.io.Serializable; +import java.util.UUID; + +public class PollOption implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private final String optionId; + private final String text; + + public PollOption(String text) { + this.optionId = UUID.randomUUID().toString(); + this.text = text; + } + + public String getOptionId() { + return optionId; + } + + public String getText() { + return text; + } +} diff --git a/src/main/java/dev/ksan/voteSystem/PollRepository.java b/src/main/java/dev/ksan/voteSystem/PollRepository.java new file mode 100644 index 0000000..cd20c64 --- /dev/null +++ b/src/main/java/dev/ksan/voteSystem/PollRepository.java @@ -0,0 +1,124 @@ +package dev.ksan.voteSystem; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.nio.channels.FileLock; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.stream.Collectors; + +public class PollRepository { + + private static final Path POLL_DIR = Path.of("data/polls"); + private static final ConcurrentHashMap locks = + new ConcurrentHashMap<>(); + + private static ReentrantReadWriteLock getLock(String pollId) { + return locks.computeIfAbsent(pollId, k -> new ReentrantReadWriteLock()); + } + + public static void save(Poll poll) throws Exception { + Files.createDirectories(POLL_DIR); + ReentrantReadWriteLock.WriteLock writeLock = getLock(poll.getPollId()).writeLock(); + writeLock.lock(); + try { + writeToFile(poll, resolvePath(poll.getPollId())); + } finally { + writeLock.unlock(); + } + } + + public static Poll findById(String pollId) throws Exception { + ReentrantReadWriteLock.ReadLock readLock = getLock(pollId).readLock(); + readLock.lock(); + try { + return (Poll) deserialize(resolvePath(pollId)); + } finally { + readLock.unlock(); + } + } + + public static List findAll() throws Exception { + if (!Files.exists(POLL_DIR)) return Collections.emptyList(); + + List results = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(POLL_DIR, "*.ser")) { + for (Path path : stream) { + String pollId = path.getFileName().toString().replace(".ser", ""); + ReentrantReadWriteLock.ReadLock readLock = getLock(pollId).readLock(); + readLock.lock(); + try { + Object obj = deserialize(path); + if (obj instanceof Poll poll) results.add(poll); + } finally { + readLock.unlock(); + } + } + } + return results; + } + + public static List findByOrganizer(String organizerId) throws Exception { + return findAll().stream() + .filter(p -> p.getOrganizerId().equals(organizerId)) + .collect(Collectors.toList()); + } + + public static List findActive() throws Exception { + return findAll().stream() + .filter(p -> p.getStatus() == PollStatus.ACTIVE) + .collect(Collectors.toList()); + } + + public static void update(Poll poll) throws Exception { + ReentrantReadWriteLock.WriteLock writeLock = getLock(poll.getPollId()).writeLock(); + writeLock.lock(); + try { + if (!Files.exists(resolvePath(poll.getPollId()))) + throw new IllegalStateException("Poll not found: " + poll.getPollId()); + writeToFile(poll, resolvePath(poll.getPollId())); + } finally { + writeLock.unlock(); + } + } + + public static boolean exists(String pollId) { + return Files.exists(resolvePath(pollId)); + } + + private static Path resolvePath(String pollId) { + return POLL_DIR.resolve(pollId + ".ser"); + } + + private static void writeToFile(Object obj, Path targetPath) throws Exception { + Path tmpPath = targetPath.resolveSibling(targetPath.getFileName() + ".tmp"); + try (FileOutputStream fos = new FileOutputStream(tmpPath.toFile()); + FileLock fileLock = fos.getChannel().lock(); + ObjectOutputStream oos = new ObjectOutputStream(fos)) { + oos.writeObject(obj); + oos.flush(); + } + Files.move(tmpPath, targetPath, + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } + + private static Object deserialize(Path path) throws Exception { + if (!Files.exists(path)) return null; + try (FileInputStream fis = new FileInputStream(path.toFile()); + FileLock fileLock = fis.getChannel().tryLock(0, Long.MAX_VALUE, true); + ObjectInputStream ois = new ObjectInputStream(fis)) { + if (fileLock == null) + throw new IllegalStateException("Could not acquire read lock on: " + path); + return ois.readObject(); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/ksan/voteSystem/PollStatus.java b/src/main/java/dev/ksan/voteSystem/PollStatus.java new file mode 100644 index 0000000..20c71d3 --- /dev/null +++ b/src/main/java/dev/ksan/voteSystem/PollStatus.java @@ -0,0 +1,7 @@ +package dev.ksan.voteSystem; + +public enum PollStatus { + ACTIVE, + PENDING, + FINISHED, +} diff --git a/src/main/java/dev/ksan/voteSystem/TallyResult.java b/src/main/java/dev/ksan/voteSystem/TallyResult.java new file mode 100644 index 0000000..2120bf7 --- /dev/null +++ b/src/main/java/dev/ksan/voteSystem/TallyResult.java @@ -0,0 +1,83 @@ +package dev.ksan.voteSystem; + +import java.io.Serial; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Map; + +public class TallyResult implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private final String pollId; + + private final String pollTitle; + + private final Map resultsByOption; + + private final int validVotes; + private final int invalidVotes; + + private final LocalDateTime talliedAt; + + private byte[] report; + private byte[] reportSignature; + + public TallyResult(String pollId, String pollTitle, Map resultsByOption, + int validVotes, int invalidVotes, LocalDateTime talliedAt) { + this.pollId = pollId; + this.pollTitle = pollTitle; + this.resultsByOption = resultsByOption; + this.validVotes = validVotes; + this.invalidVotes = invalidVotes; + this.talliedAt = talliedAt; + } + + public String getPollId() { + return pollId; + } + + public String getPollTitle() { + return pollTitle; + } + + public Map getResultsByOption() { + return resultsByOption; + } + + public int getValidVotes() { + return validVotes; + } + + public int getInvalidVotes() { + return invalidVotes; + } + + public LocalDateTime getTalliedAt() { + return talliedAt; + } + + public byte[] getReport() { + return report; + } + + public byte[] getReportSignature() { + return reportSignature; + } + + public void setReport(byte[] report) { + this.report = report; + } + + public void setReportSignature(byte[] sig) { + this.reportSignature = sig; + } + + public void printReport() { + System.out.println(new String(report, StandardCharsets.UTF_8)); + System.out.println("Signature (Base64): " + + Base64.getEncoder().encodeToString(reportSignature)); + } +} diff --git a/src/main/java/dev/ksan/voteSystem/TallyResultRepository.java b/src/main/java/dev/ksan/voteSystem/TallyResultRepository.java new file mode 100644 index 0000000..3ec5d54 --- /dev/null +++ b/src/main/java/dev/ksan/voteSystem/TallyResultRepository.java @@ -0,0 +1,29 @@ +package dev.ksan.voteSystem; + +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +public class TallyResultRepository { + + private static final Path TALLY_DIR = Path.of("data/tallies"); + + public static void save(TallyResult result) throws Exception { + Files.createDirectories(TALLY_DIR); + Path path = TALLY_DIR.resolve(result.getPollId() + ".ser"); + try (ObjectOutputStream oos = new ObjectOutputStream( + Files.newOutputStream(path))) { + oos.writeObject(result); + } + } + + public static TallyResult findByPoll(String pollId) throws Exception { + Path path = TALLY_DIR.resolve(pollId + ".ser"); + if (!Files.exists(path)) return null; + try (ObjectInputStream ois = new ObjectInputStream( + Files.newInputStream(path))) { + return (TallyResult) ois.readObject(); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/ksan/voteSystem/TallyService.java b/src/main/java/dev/ksan/voteSystem/TallyService.java new file mode 100644 index 0000000..9116b4d --- /dev/null +++ b/src/main/java/dev/ksan/voteSystem/TallyService.java @@ -0,0 +1,169 @@ +package dev.ksan.voteSystem; + +import dev.ksan.Users.Organizer; +import dev.ksan.Users.UserRepository; +import dev.ksan.Users.Voter; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class TallyService { + + private static final String AES_MODE = "AES/CBC/PKCS5Padding"; + private static final String RSA_MODE = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"; + + public static TallyResult tallyPoll( + Poll poll, + Organizer organizer, + PrivateKey organizerPrivateKey + ) throws Exception { + + if (poll.getStatus() != PollStatus.FINISHED) + throw new IllegalStateException("Poll has not finished yet."); + + if (poll.isTallied()) + throw new IllegalStateException("Poll has already been tallied."); + + if (!poll.getOrganizerId().equals(organizer.getId())) + throw new IllegalStateException("This poll does not belong to this organizer."); + + List votes = VoteRepository.findAllByPoll(poll.getPollId()); + + Map counts = new LinkedHashMap<>(); + for (PollOption option : poll.getOptions()) { + counts.put(option.getOptionId(), 0); + } + + int validVotes = 0; + int invalidVotes = 0; + + for (Vote vote : votes) { + + Voter voter = UserRepository.findVoter(vote.getVoterId()); + if (voter == null) { + invalidVotes++; + continue; + } + + boolean signatureValid = verifySignature( + vote.getEncryptedVote(), + vote.getPollId(), + vote.getVoterId(), + vote.getSignature(), + voter.getPublicKey() + ); + + if (!signatureValid) { + System.out.println("Invalid signature for voter: " + vote.getVoterId()); + invalidVotes++; + continue; + } + + Cipher rsaCipher = Cipher.getInstance(RSA_MODE, "BC"); + rsaCipher.init(Cipher.DECRYPT_MODE, organizerPrivateKey); + byte[] aesKeyBytes = rsaCipher.doFinal(vote.getEncryptedSymmetricKey()); + SecretKey aesKey = new SecretKeySpec(aesKeyBytes, "AES"); + + Cipher aesCipher = Cipher.getInstance(AES_MODE); + aesCipher.init(Cipher.DECRYPT_MODE, aesKey, new IvParameterSpec(vote.getIv())); + String optionId = new String( + aesCipher.doFinal(vote.getEncryptedVote()), StandardCharsets.UTF_8); + + if (counts.containsKey(optionId)) { + counts.merge(optionId, 1, Integer::sum); + validVotes++; + } else { + invalidVotes++; + } + } + + Map resultsByText = new LinkedHashMap<>(); + for (PollOption option : poll.getOptions()) { + resultsByText.put(option.getText(), counts.get(option.getOptionId())); + } + + TallyResult result = new TallyResult( + poll.getPollId(), + poll.getTitle(), + resultsByText, + validVotes, + invalidVotes, + LocalDateTime.now() + ); + + byte[] report = generateReport(result, poll, organizer); + byte[] reportSignature = signReport(report, organizerPrivateKey); + result.setReport(report); + result.setReportSignature(reportSignature); + + poll.setTallied(true); + PollRepository.update(poll); + + TallyResultRepository.save(result); + + return result; + } + + private static boolean verifySignature( + byte[] encryptedVote, String pollId, + String voterId, byte[] signature, + PublicKey voterPublicKey + ) { + try { + byte[] data = buildSignableData(encryptedVote, pollId, voterId); + Signature sig = Signature.getInstance("SHA256withRSA", "BC"); + sig.initVerify(voterPublicKey); + sig.update(data); + return sig.verify(signature); + } catch (Exception e) { + return false; + } + } + + private static byte[] buildSignableData( + byte[] encryptedVote, String pollId, String voterId) { + byte[] pollIdBytes = pollId.getBytes(StandardCharsets.UTF_8); + byte[] voterIdBytes = voterId.getBytes(StandardCharsets.UTF_8); + byte[] combined = new byte[encryptedVote.length + pollIdBytes.length + voterIdBytes.length]; + System.arraycopy(encryptedVote, 0, combined, 0, encryptedVote.length); + System.arraycopy(pollIdBytes, 0, combined, encryptedVote.length, pollIdBytes.length); + System.arraycopy(voterIdBytes, 0, combined, + encryptedVote.length + pollIdBytes.length, voterIdBytes.length); + return combined; + } + + private static byte[] generateReport( + TallyResult result, Poll poll, Organizer organizer) { + StringBuilder sb = new StringBuilder(); + sb.append("=== TALLY REPORT ===\n"); + sb.append("Poll : ").append(poll.getTitle()).append("\n"); + sb.append("Organizer: ").append(organizer.getOrganizationName()).append("\n"); + sb.append("Period : ").append(poll.getStartTime()) + .append(" -> ").append(poll.getEndTime()).append("\n"); + sb.append("Tallied : ").append(result.getTalliedAt()).append("\n"); + sb.append("--------------------\n"); + result.getResultsByOption().forEach((option, count) -> + sb.append(option).append(": ").append(count).append(" votes\n")); + sb.append("--------------------\n"); + sb.append("Valid votes : ").append(result.getValidVotes()).append("\n"); + sb.append("Invalid votes: ").append(result.getInvalidVotes()).append("\n"); + return sb.toString().getBytes(StandardCharsets.UTF_8); + } + + private static byte[] signReport(byte[] report, PrivateKey key) throws Exception { + Signature sig = Signature.getInstance("SHA256withRSA", "BC"); + sig.initSign(key); + sig.update(report); + return sig.sign(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/ksan/voteSystem/Vote.java b/src/main/java/dev/ksan/voteSystem/Vote.java new file mode 100644 index 0000000..7ff4867 --- /dev/null +++ b/src/main/java/dev/ksan/voteSystem/Vote.java @@ -0,0 +1,79 @@ +package dev.ksan.voteSystem; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.UUID; + +public class Vote implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private final String voteId; + private final String pollId; + private final String voterId; + + private final byte[] encryptedVote; + + private final byte[] encryptedSymmetricKey; + + // AES IV needed for decryption + private final byte[] iv; + + // voter digital signature over (encryptedVote + pollId + voterId) + private final byte[] signature; + + private final byte[] metadataHmac; + + private final LocalDateTime castTime; + + public Vote(String pollId, String voterId, + byte[] encryptedVote, byte[] encryptedSymmetricKey, + byte[] iv, byte[] signature, byte[] metadataHmac) { + this.voteId = UUID.randomUUID().toString(); + this.pollId = pollId; + this.voterId = voterId; + this.encryptedVote = encryptedVote; + this.encryptedSymmetricKey = encryptedSymmetricKey; + this.iv = iv; + this.signature = signature; + this.metadataHmac = metadataHmac; + this.castTime = LocalDateTime.now(); + } + + public String getVoteId() { + return voteId; + } + + public String getPollId() { + return pollId; + } + + public String getVoterId() { + return voterId; + } + + public byte[] getEncryptedVote() { + return encryptedVote; + } + + public byte[] getEncryptedSymmetricKey() { + return encryptedSymmetricKey; + } + + public byte[] getIv() { + return iv; + } + + public byte[] getSignature() { + return signature; + } + + public byte[] getMetadataHmac() { + return metadataHmac; + } + + public LocalDateTime getCastTime() { + return castTime; + } +} diff --git a/src/main/java/dev/ksan/voteSystem/VoteRepository.java b/src/main/java/dev/ksan/voteSystem/VoteRepository.java new file mode 100644 index 0000000..719f94e --- /dev/null +++ b/src/main/java/dev/ksan/voteSystem/VoteRepository.java @@ -0,0 +1,116 @@ +package dev.ksan.voteSystem; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.nio.channels.FileLock; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +public class VoteRepository { + + private static final Path VOTE_DIR = Path.of("data/votes"); + private static final ConcurrentHashMap locks = + new ConcurrentHashMap<>(); + + private static ReentrantReadWriteLock getLock(String key) { + return locks.computeIfAbsent(key, k -> new ReentrantReadWriteLock()); + } + + public static void save(Vote vote) throws Exception { + Path path = resolvePath(vote.getPollId(), vote.getVoterId()); + Files.createDirectories(path.getParent()); + + String key = lockKey(vote.getPollId(), vote.getVoterId()); + ReentrantReadWriteLock.WriteLock writeLock = getLock(key).writeLock(); + writeLock.lock(); + try { + if (Files.exists(path)) + throw new IllegalStateException("Vote already exists for this voter in this poll."); + writeToFile(vote, path); + } finally { + writeLock.unlock(); + } + } + + public static Vote findByVoterAndPoll(String voterId, String pollId) { + Path path = resolvePath(pollId, voterId); + String key = lockKey(pollId, voterId); + + ReentrantReadWriteLock.ReadLock readLock = getLock(key).readLock(); + readLock.lock(); + try { + return (Vote) deserialize(path); + } catch (Exception e) { + return null; + } finally { + readLock.unlock(); + } + } + + public static List findAllByPoll(String pollId) throws Exception { + Path pollVoteDir = VOTE_DIR.resolve(pollId); + if (!Files.exists(pollVoteDir)) return Collections.emptyList(); + + List votes = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(pollVoteDir, "*.ser")) { + for (Path path : stream) { + String voterId = path.getFileName().toString().replace(".ser", ""); + String key = lockKey(pollId, voterId); + + ReentrantReadWriteLock.ReadLock readLock = getLock(key).readLock(); + readLock.lock(); + try { + Object obj = deserialize(path); + if (obj instanceof Vote vote) votes.add(vote); + } finally { + readLock.unlock(); + } + } + } + return votes; + } + + public static int countByPoll(String pollId) throws Exception { + return findAllByPoll(pollId).size(); + } + + private static Path resolvePath(String pollId, String voterId) { + return VOTE_DIR.resolve(pollId).resolve(voterId + ".ser"); + } + + private static String lockKey(String pollId, String voterId) { + return pollId + ":" + voterId; + } + + private static void writeToFile(Object obj, Path targetPath) throws Exception { + Path tmpPath = targetPath.resolveSibling(targetPath.getFileName() + ".tmp"); + try (FileOutputStream fos = new FileOutputStream(tmpPath.toFile()); + FileLock fileLock = fos.getChannel().lock(); + ObjectOutputStream oos = new ObjectOutputStream(fos)) { + oos.writeObject(obj); + oos.flush(); + } + Files.move(tmpPath, targetPath, + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } + + private static Object deserialize(Path path) throws Exception { + if (!Files.exists(path)) return null; + try (FileInputStream fis = new FileInputStream(path.toFile()); + FileLock fileLock = fis.getChannel().tryLock(0, Long.MAX_VALUE, true); + ObjectInputStream ois = new ObjectInputStream(fis)) { + if (fileLock == null) + throw new IllegalStateException("Could not acquire read lock on: " + path); + return ois.readObject(); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/ksan/voteSystem/VotingService.java b/src/main/java/dev/ksan/voteSystem/VotingService.java new file mode 100644 index 0000000..f40090f --- /dev/null +++ b/src/main/java/dev/ksan/voteSystem/VotingService.java @@ -0,0 +1,122 @@ +package dev.ksan.voteSystem; + +import java.nio.charset.StandardCharsets; +import java.security.*; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import dev.ksan.Users.Voter; + +public class VotingService { + + private static final String AES = "AES"; + private static final String AES_MODE = "AES/CBC/PKCS5Padding"; + private static final String RSA_MODE = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"; + private static final String HMAC_ALGO = "HmacSHA256"; + private static final String BC = "BC"; + + public static Vote castVote( + String selectedOptionId, + Poll poll, + Voter voter, + PrivateKey voterPrivateKey, + PublicKey organizerPublicKey) throws Exception { + + if (poll.getStatus() != PollStatus.ACTIVE) + throw new IllegalStateException("Poll is not active."); + + if (hasVoted(voter.getUsername(), poll.getPollId())) + throw new IllegalStateException("You have already voted in this poll."); + + KeyGenerator keyGen = KeyGenerator.getInstance(AES, BC); + keyGen.init(256, new SecureRandom()); + SecretKey aesKey = keyGen.generateKey(); + + byte[] iv = new byte[16]; + new SecureRandom().nextBytes(iv); + + Cipher aesCipher = Cipher.getInstance(AES_MODE); + aesCipher.init(Cipher.ENCRYPT_MODE, aesKey, new IvParameterSpec(iv)); + byte[] encryptedVote = aesCipher.doFinal(selectedOptionId.getBytes(StandardCharsets.UTF_8)); + + Cipher rsaCipher = Cipher.getInstance(RSA_MODE, BC); + rsaCipher.init(Cipher.ENCRYPT_MODE, organizerPublicKey); + byte[] encryptedSymmetricKey = rsaCipher.doFinal(aesKey.getEncoded()); + + byte[] dataToSign = buildSignableData(encryptedVote, poll.getPollId(), voter.getUsername()); + byte[] signature = signData(dataToSign, voterPrivateKey); + + byte[] hmacKey = generateHmacKey(); + byte[] metadata = buildMetadata(poll.getPollId(), voter.getUsername()); + byte[] metadataHmac = computeHmac(metadata, hmacKey); + + HmacKeyRepository.save(voter.getUsername(), poll.getPollId(), hmacKey); + + Vote vote = new Vote( + poll.getPollId(), + voter.getUsername(), + encryptedVote, + encryptedSymmetricKey, + iv, + signature, + metadataHmac); + + VoteRepository.save(vote); + return vote; + } + + public static boolean verifyVote(String voterId, String pollId) throws Exception { + Vote vote = VoteRepository.findByVoterAndPoll(voterId, pollId); + if (vote == null) + return false; + + byte[] hmacKey = HmacKeyRepository.load(voterId, pollId); + byte[] metadata = buildMetadata(pollId, voterId); + byte[] expectedHmac = computeHmac(metadata, hmacKey); + + return MessageDigest.isEqual(expectedHmac, vote.getMetadataHmac()); + } + + public static boolean hasVoted(String voterId, String pollId) { + return VoteRepository.findByVoterAndPoll(voterId, pollId) != null; + } + + private static byte[] buildSignableData(byte[] encryptedVote, + String pollId, String voterId) { + byte[] pollIdBytes = pollId.getBytes(StandardCharsets.UTF_8); + byte[] voterIdBytes = voterId.getBytes(StandardCharsets.UTF_8); + byte[] combined = new byte[encryptedVote.length + pollIdBytes.length + voterIdBytes.length]; + System.arraycopy(encryptedVote, 0, combined, 0, encryptedVote.length); + System.arraycopy(pollIdBytes, 0, combined, encryptedVote.length, pollIdBytes.length); + System.arraycopy(voterIdBytes, 0, combined, encryptedVote.length + pollIdBytes.length, voterIdBytes.length); + return combined; + } + + private static byte[] buildMetadata(String pollId, String voterId) { + return (pollId + "|" + voterId).getBytes(StandardCharsets.UTF_8); + } + + private static byte[] signData(byte[] data, PrivateKey key) throws Exception { + Signature sig = Signature.getInstance("SHA256withRSA", BC); + sig.initSign(key); + sig.update(data); + return sig.sign(); + } + + private static byte[] generateHmacKey() { + byte[] key = new byte[32]; + new SecureRandom().nextBytes(key); + return key; + } + + private static byte[] computeHmac(byte[] data, byte[] key) throws Exception { + Mac mac = Mac.getInstance(HMAC_ALGO); + mac.init(new SecretKeySpec(key, HMAC_ALGO)); + return mac.doFinal(data); + } +} diff --git a/src/main/resources/fxml/OrganizerView.fxml b/src/main/resources/fxml/OrganizerView.fxml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/fxml/VoterView.fxml b/src/main/resources/fxml/VoterView.fxml new file mode 100644 index 0000000..e69de29