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