Compare commits

..

2 Commits

13 changed files with 409 additions and 223 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

24
.gitignore vendored
View File

@@ -1,24 +1,2 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules node_modules
.nuxt
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

View File

@@ -11,6 +11,12 @@ A decentralized, real-time polling application built with **Nuxt 3**, **Yjs**, a
* **Cryptographic Integrity:** Every vote is signed using **RSA-PSS (Web Crypto API)**. Each user has a unique private key (stored locally via `.pem` files) to ensure votes cannot be forged or tampered with. * **Cryptographic Integrity:** Every vote is signed using **RSA-PSS (Web Crypto API)**. Each user has a unique private key (stored locally via `.pem` files) to ensure votes cannot be forged or tampered with.
* **Chained Verification:** Implements a "History-Signing" logic where each new vote signs the entire preceding state of the poll, creating a verifiable chain of trust. * **Chained Verification:** Implements a "History-Signing" logic where each new vote signs the entire preceding state of the poll, creating a verifiable chain of trust.
* **Privacy First:** Users identify via UUIDs and Public/Private key pairs rather than traditional accounts. * **Privacy First:** Users identify via UUIDs and Public/Private key pairs rather than traditional accounts.
* **Vote Uniqueness:** Enforces one vote per option per user to prevent duplicate voting.
* **Public Key Caching:** Snapshot API includes all user public keys for efficient client-side verification.
* **Relative Poll Timeframe:** Uses `createdAt + duration` model instead of absolute timestamps to avoid clock synchronization issues.
* **Secure User Creation:** User creation endpoint protected with API key authentication and rate limiting.
* **Modern UI:** Dark theme with glassmorphism effects for a contemporary user experience.
* **Error Handling:** User-friendly alerts for authentication errors and missing public keys.
--- ---

View File

@@ -1,10 +1,11 @@
<style> <style>
/* Basic styling to make it look clean */ /* Modern dark theme with glassmorphism */
body { body {
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
background-color: #f4f4f9; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
color: #333; color: #fff;
margin: 0; margin: 0;
min-height: 100vh;
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 2rem; padding: 2rem;
@@ -12,56 +13,94 @@ body {
header { header {
margin-bottom: 2rem; margin-bottom: 2rem;
text-align: center; text-align: left;
} }
h1 { margin: 0 0 0.5rem 0; } h1 {
margin: 0 0 0.5rem 0;
font-size: 2.5rem;
font-weight: bold;
color: #fff;
}
h2 {
margin: 0.5rem 0;
color: #fff;
}
input { input {
flex-grow: 1; flex-grow: 1;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid #ccc; border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px; border-radius: 8px;
font-size: 1rem; font-size: 1rem;
background: rgba(255, 255, 255, 0.1);
color: #fff;
backdrop-filter: blur(10px);
}
input::placeholder {
color: rgba(255, 255, 255, 0.5);
} }
button, button,
.button { .button {
background: #3b82f6; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
border: none; border: none;
padding: 0.75rem 1rem; padding: 0.75rem 1.5rem;
border-radius: 6px; border-radius: 8px;
cursor: pointer; cursor: pointer;
font-weight: bold; font-weight: 600;
transition: background 0.2s; transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
} }
button:hover, button:hover,
.button:hover { background: #2563eb; } .button:hover {
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.status { .status {
font-size: 0.85rem; font-size: 0.9rem;
color: #666; color: rgba(255, 255, 255, 0.7);
}
.status .connected {
color: #10b981;
font-weight: bold;
text-shadow: 0 0 10px rgba(16, 185, 129, 0.5);
} }
.status .connected { color: #10b981; font-weight: bold; }
.connectionFailed { color: #FF2525; font-weight: bold; } .connectionFailed {
color: #ff6b6b;
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 107, 107, 0.5);
}
.poll-container { .poll-container {
background: white; background: rgba(255, 255, 255, 0.1);
padding: 2rem; backdrop-filter: blur(20px);
border-radius: 12px; -webkit-backdrop-filter: blur(20px);
box-shadow: 0 4px 6px rgba(0,0,0,0.1); padding: 2.5rem;
border-radius: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
width: 100%; width: 100%;
max-width: 500px; max-width: 600px;
} }
.back-btn { .back-btn {
margin-left: 1rem; margin-left: 1rem;
padding: 0.2rem 0.5rem; padding: 0.5rem 1rem;
font-size: 0.7rem; font-size: 0.85rem;
background: #64748b; background: rgba(100, 116, 139, 0.8);
backdrop-filter: blur(10px);
}
.back-btn:hover {
background: rgba(100, 116, 139, 1);
} }
/* Hide the actual file input */ /* Hide the actual file input */
@@ -69,6 +108,30 @@ input[type="file"] {
display: none; display: none;
} }
/* Add subtle animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.poll-container {
animation: fadeIn 0.5s ease-out;
}
/* Responsive design */
@media (max-width: 640px) {
body {
padding: 1rem;
}
.poll-container {
padding: 1.5rem;
}
h1 {
font-size: 2rem;
}
}
</style> </style>
<template> <template>
<div class="poll-container"> <div class="poll-container">
@@ -92,6 +155,16 @@ input[type="file"] {
@change="loadUser" @change="loadUser"
/> />
</label> </label>
<div style="margin-top: 10px;">
<label title="Register Public Key">
<span class="button" style="font-size: 0.8rem; padding: 0.5rem 1rem;">Register Public Key</span>
<input
type="file"
accept=".pem"
@change="registerPublicKey"
/>
</label>
</div>
</div> </div>
</div> </div>
</header> </header>
@@ -105,6 +178,7 @@ input[type="file"] {
<script setup lang="ts"> <script setup lang="ts">
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { generateUserKeyPair, exportPrivateKey, savePrivateKeyToFile, exportPublicKey, stringToCryptoKey } from '~/utils/crypto';
const activePollId = ref<string | null>(null); const activePollId = ref<string | null>(null);
const user = shallowRef<UserData | null>(null); const user = shallowRef<UserData | null>(null);
@@ -128,12 +202,32 @@ input[type="file"] {
const prvKeyString = await exportPrivateKey(keypair.privateKey); const prvKeyString = await exportPrivateKey(keypair.privateKey);
await savePrivateKeyToFile(prvKeyString,uuid+".pem") await savePrivateKeyToFile(prvKeyString,uuid+".pem")
const pubKeyString = await exportPublicKey(keypair.publicKey); const pubKeyString = await exportPublicKey(keypair.publicKey);
// Save public key to server
await $fetch(`/api/users/${uuid}`, { await $fetch(`/api/users/${uuid}`, {
method: 'POST', method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ADMIN_API_KEY || 'default-admin-key-change-in-production'}`
},
body: { public_key: pubKeyString } body: { public_key: pubKeyString }
}); });
// Also save public key to a file for backup
const pubPemHeader = "-----BEGIN PUBLIC KEY-----\n";
const pubPemFooter = "\n-----END PUBLIC KEY-----";
const pubFileContent = pubPemHeader + pubKeyString + pubPemFooter;
const blob = new Blob([pubFileContent], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = uuid + "_public.pem";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
console.log("User created successfully. Please save both key files.");
} catch (err) { } catch (err) {
user.value = null user.value = null
console.error("Failed to create new User!", err); console.error("Failed to create new User!", err);
@@ -149,7 +243,7 @@ input[type="file"] {
console.log("File loaded: "); console.log("File loaded: ");
if (file.name && content) { if (file.name && content) {
try { try {
const uuid = file.name.replace(".pem", ""); const uuid = file.name.replace(".pem", "").replace("_public", "");
// Standardize the string for the importer // Standardize the string for the importer
const pkBase64 = content.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----/g, "").replace(/\s+/g, ""); const pkBase64 = content.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----/g, "").replace(/\s+/g, "");
@@ -158,7 +252,7 @@ input[type="file"] {
user.value = { user.value = {
userid: uuid, userid: uuid,
private_key: key, private_key: key,
public_key: undefined, // Note: You might need to import a pub key too! public_key: undefined,
}; };
console.log("Login successful for:", uuid); console.log("Login successful for:", uuid);
@@ -172,4 +266,43 @@ input[type="file"] {
} }
} }
}; };
const registerPublicKey = async (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
try {
const content = await file.text();
if (file.name && content) {
try {
const uuid = file.name.replace(".pem", "").replace("_public", "");
console.log("Attempting to register public key for user:", uuid);
// Standardize the string for the importer
const pubKeyBase64 = content.replace(/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----/g, "").replace(/\s+/g, "");
console.log("Public key length:", pubKeyBase64.length);
// Save public key to server
await $fetch(`/api/users/${uuid}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ADMIN_API_KEY || 'default-admin-key-change-in-production'}`
},
body: { public_key: pubKeyBase64 }
});
alert(`Public key registered successfully for user: ${uuid}`);
} catch (err: any) {
console.error("Registration Error:", err);
const errorMsg = err.data?.message || err.statusMessage || err.message || "Unknown error";
alert(`Failed to register public key: ${errorMsg}`);
}
}
} catch (e) {
console.error("Failed to read file", e);
alert("Failed to read file.");
}
}
};
</script> </script>

View File

@@ -10,16 +10,26 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
background: #f8fafc; background: rgba(255, 255, 255, 0.1);
border: 1px solid #e2e8f0; backdrop-filter: blur(10px);
border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
transition: all 0.3s ease;
} }
.poll-item:hover {
background: rgba(255, 255, 255, 0.15);
transform: translateX(5px);
}
.poll-title { .poll-title {
font-size: 1.1rem; font-size: 1.5rem;
color: #3b82f6; color: #667eea;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
font-weight: bold;
margin-bottom: 1rem;
} }
.add-option-form { .add-option-form {
@@ -28,16 +38,42 @@
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.option-name { font-weight: 500; } .option-name {
font-weight: 500;
color: #fff;
}
.vote-section { display: flex; align-items: center; gap: 1rem; } .vote-section { display: flex; align-items: center; gap: 1rem; }
.vote-count { font-size: 0.9rem; color: #475569; } .vote-count { font-size: 0.9rem; color: rgba(255, 255, 255, 0.8); }
.vote-btn { padding: 0.4rem 0.8rem; background: #10b981; } .vote-btn {
.vote-btn:hover { background: #059669; } padding: 0.5rem 1rem;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border: none;
border-radius: 8px;
color: white;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);
}
.vote-btn:hover {
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.6);
}
.vote-btn:disabled, .vote-btn:disabled,
.vote-btn[disabled] { background: #888888; } .vote-btn[disabled] {
background: rgba(136, 136, 136, 0.5);
cursor: not-allowed;
}
.vote-btn:disabled:hover, .vote-btn:disabled:hover,
.vote-btn[disabled]:hover { background: #AAAAAA; } .vote-btn[disabled]:hover {
background: rgba(136, 136, 136, 0.7);
transform: none;
}
p {
color: rgba(255, 255, 255, 0.6);
}
</style> </style>
<template> <template>

View File

@@ -1,18 +1,29 @@
<style scoped> <style scoped>
.poll-list { margin-top: 1rem; } .poll-list { margin-top: 1rem; }
.empty-state { text-align: center; color: #94a3b8; font-style: italic; } .empty-state { text-align: center; color: rgba(255, 255, 255, 0.6); font-style: italic; }
.create-poll { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; } .create-poll { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
.poll-links { list-style: none; padding: 0; } .poll-links { list-style: none; padding: 0; }
.poll-link-btn { .poll-link-btn {
width: 100%; width: 100%;
text-align: left; text-align: left;
background: #f1f5f9; background: rgba(255, 255, 255, 0.1);
color: #1e293b; color: #fff;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
}
.poll-link-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateX(5px);
}
h3 {
color: #fff;
margin-bottom: 1rem;
font-size: 1.5rem;
} }
.poll-link-btn:hover { background: #e2e8f0; }
</style> </style>
<template> <template>

View File

@@ -1,6 +1,8 @@
// composables/usePoll.ts // composables/usePoll.ts
import { ref, watch, onUnmounted } from 'vue'; import { ref, watch, onUnmounted } from 'vue';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { stringToCryptoKey, verifyAllVotesForOption } from '~/utils/crypto';
import type { SignedData, PollMetadata } from '~/utils/types';
export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>) => { export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>) => {
const pollData = ref<PollData>({}); const pollData = ref<PollData>({});
@@ -11,12 +13,14 @@ export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>)
let ydoc: Y.Doc | null = null; let ydoc: Y.Doc | null = null;
let provider: any = null; let provider: any = null;
let yMap: Y.Map<SignedData<VoteData>[]> | null = null; let yMap: Y.Map<SignedData<VoteData>[]> | null = null;
let publicKeysCache: Record<string, CryptoKey> = {};
const cleanup = () => { const cleanup = () => {
if (provider) provider.disconnect(); if (provider) provider.disconnect();
if (ydoc) ydoc.destroy(); if (ydoc) ydoc.destroy();
isConnected.value = false; isConnected.value = false;
pollData.value = {}; pollData.value = {};
publicKeysCache = {};
}; };
const initPoll = async (id: string) => { const initPoll = async (id: string) => {
@@ -26,9 +30,22 @@ export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>)
// 1. Fetch Snapshot from Nuxt API // 1. Fetch Snapshot from Nuxt API
try { try {
const response = await $fetch<{ update: number[] | null }>(`/api/polls/${id}`).catch((e) => { const response = await $fetch<{ update: number[] | null, publicKeys: Record<string, string> }>(`/api/polls/${id}`).catch((e) => {
console.error("Failed to get poll: " + id,e) console.error("Failed to get poll: " + id,e)
}); });
// Cache public keys from snapshot
if (response?.publicKeys) {
for (const [userId, publicKeyStr] of Object.entries(response.publicKeys)) {
try {
publicKeysCache[userId] = await stringToCryptoKey(publicKeyStr, 'public');
} catch (e) {
console.error(`Failed to cache public key for user ${userId}:`, e);
}
}
console.log(`Cached ${Object.keys(publicKeysCache).length} public keys`);
}
//trust the server without verification. //trust the server without verification.
if (response?.update) { if (response?.update) {
Y.applyUpdate(ydoc, new Uint8Array(response.update)); Y.applyUpdate(ydoc, new Uint8Array(response.update));
@@ -67,12 +84,21 @@ export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>)
const saveStateToServer = async (id: string) => { const saveStateToServer = async (id: string) => {
if (!ydoc) return; if (!ydoc) return;
const stateUpdate = Y.encodeStateAsUpdate(ydoc); const stateUpdate = Y.encodeStateAsUpdate(ydoc);
await $fetch(`/api/polls/${id}`, { try {
method: 'POST', await $fetch(`/api/polls/${id}`, {
body: { update: Array.from(stateUpdate) } method: 'POST',
}).catch((e) => { body: { update: Array.from(stateUpdate) }
console.error("Failed to update poll",e) });
}); } catch (e: any) {
console.error("Failed to update poll", e);
if (e.data?.message) {
alert(`Error: ${e.data.message}`);
} else if (e.statusMessage) {
alert(`Error: ${e.statusMessage}`);
} else {
alert('Failed to save poll. Please try again.');
}
}
}; };
// Watch for ID changes (e.g., user clicks a link or goes back) // Watch for ID changes (e.g., user clicks a link or goes back)
@@ -93,10 +119,18 @@ export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>)
const performUpdateAndVerify = async () => { const performUpdateAndVerify = async () => {
const pollDataUpdate = yMap!.toJSON(); const pollDataUpdate = yMap!.toJSON();
console.log("Poll Data Update: ", pollDataUpdate) console.log("Poll Data Update: ", pollDataUpdate)
// Extract poll metadata if it exists
const metadataSigned = pollDataUpdate['_metadata'] as SignedData<PollMetadata> | undefined;
const pollMetadata = metadataSigned?.data;
for(var option in pollDataUpdate){ for(var option in pollDataUpdate){
// Skip metadata key when iterating over options
if (option === '_metadata') continue;
console.log("verifying votes for option: " + option); console.log("verifying votes for option: " + option);
const votes = pollDataUpdate[option] || []; const votes = pollDataUpdate[option] || [];
const verified = await verifyAllVotesForOption(votes); const verified = await verifyAllVotesForOption(votes, publicKeysCache, pollMetadata);
if(!verified){ if(!verified){
console.error("Failed to verify option: "+option) console.error("Failed to verify option: "+option)
return; return;
@@ -108,8 +142,21 @@ export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>)
const vote = async (optionName: string) => { const vote = async (optionName: string) => {
const currentUser = user.value; const currentUser = user.value;
if (currentUser != undefined && yMap?.has(optionName)) { if (currentUser == undefined) {
alert('You must be logged in to vote. Please create a user or login with your key file.');
return;
}
if (yMap?.has(optionName)) {
const voteData = [...(yMap.get(optionName) || [])]; const voteData = [...(yMap.get(optionName) || [])];
// Check if user has already voted for this option
const hasAlreadyVoted = voteData.some(v => v.data.userid === currentUser.userid);
if (hasAlreadyVoted) {
console.error(`User ${currentUser.userid} has already voted for option ${optionName}`);
alert('You have already voted for this option.');
return;
}
if(voteData != undefined && currentUser.private_key){ if(voteData != undefined && currentUser.private_key){
var unsignedVoteData : VoteData = { var unsignedVoteData : VoteData = {
userid: currentUser.userid, userid: currentUser.userid,

View File

@@ -1,4 +1,6 @@
// utils/crypto.ts // utils/crypto.ts
import type { SignedData, VoteData, PollMetadata } from "./types";
export const generateUserKeyPair = async () => { export const generateUserKeyPair = async () => {
return await window.crypto.subtle.generateKey( return await window.crypto.subtle.generateKey(
{ {
@@ -47,12 +49,24 @@ export const verifyVote = async (data: any, signatureStr: string, publicKey: Cry
*/ */
export const verifyChainedVote = async ( export const verifyChainedVote = async (
voteData: SignedData<VoteData>[], voteData: SignedData<VoteData>[],
index: number index: number,
publicKeysCache?: Record<string, CryptoKey>,
pollMetadata?: PollMetadata
) => { ) => {
const voteToVerify = voteData[index]; const voteToVerify = voteData[index];
console.log("Verifying vote: " + voteToVerify) console.log("Verifying vote: " + voteToVerify)
if(voteToVerify) { if(voteToVerify) {
// 1. Reconstruct the exact data state the user signed // 1. Check vote timestamp against poll duration if metadata is available
if (pollMetadata) {
const voteTime = new Date(voteToVerify.data.timestamp).getTime();
const timeSinceCreation = voteTime - pollMetadata.createdAt;
if (timeSinceCreation > pollMetadata.duration) {
console.error(`Vote timestamp exceeds poll duration: ${timeSinceCreation}ms > ${pollMetadata.duration}ms`);
return false;
}
}
// 2. Reconstruct the exact data state the user signed
// We need the array exactly as it was when they pushed their vote // We need the array exactly as it was when they pushed their vote
const historicalState = voteData.slice(0, index + 1).map((v, i) => { const historicalState = voteData.slice(0, index + 1).map((v, i) => {
if (i === index) { if (i === index) {
@@ -64,13 +78,19 @@ export const verifyChainedVote = async (
}); });
try { try {
// 2. Fetch public key // 3. Get public key from cache or fetch from API
const response = await $fetch<{ public_key: string }>(`/api/users/${voteToVerify.data.userid}`); let pubKey: CryptoKey;
console.log("Got key: ",response) if (publicKeysCache && publicKeysCache[voteToVerify.data.userid]) {
const pubKey = await stringToCryptoKey(response.public_key, 'public'); pubKey = publicKeysCache[voteToVerify.data.userid];
console.log("Using cached public key for user:", voteToVerify.data.userid);
} else {
const response = await $fetch<{ public_key: string }>(`/api/users/${voteToVerify.data.userid}`);
console.log("Got key from API: ",response)
pubKey = await stringToCryptoKey(response.public_key, 'public');
}
console.log("Using pubKey to verify Vote.") console.log("Using pubKey to verify Vote.")
// 3. Verify: Does this historicalState match the signature? // 4. Verify: Does this historicalState match the signature?
return await verifyVote(historicalState, voteToVerify.signature, pubKey); return await verifyVote(historicalState, voteToVerify.signature, pubKey);
} catch (err) { } catch (err) {
console.error("Verification failed") console.error("Verification failed")
@@ -82,10 +102,14 @@ export const verifyChainedVote = async (
return false; return false;
}; };
export const verifyAllVotesForOption = async (votes: SignedData<VoteData>[]) => { export const verifyAllVotesForOption = async (
votes: SignedData<VoteData>[],
publicKeysCache?: Record<string, CryptoKey>,
pollMetadata?: PollMetadata
) => {
console.log("verifying votes for option ",votes); console.log("verifying votes for option ",votes);
for (let i = votes.length-1; i >= 0 ; i--) { for (let i = votes.length-1; i >= 0 ; i--) {
const isValid = await verifyChainedVote(votes, i); const isValid = await verifyChainedVote(votes, i, publicKeysCache, pollMetadata);
if(!isValid){ if(!isValid){
console.error("Error! Invalid Vote at: " + i,votes) console.error("Error! Invalid Vote at: " + i,votes)
return false; return false;

View File

@@ -10,7 +10,14 @@ export interface PollListProps {
userid: string | undefined, userid: string | undefined,
} }
export interface PollData extends Record<string, SignedData<VoteData>[]> { export interface PollMetadata {
createdAt: number; // Unix timestamp in milliseconds
duration: number; // Duration in milliseconds
createdBy: string;
}
export interface PollData {
[key: string]: SignedData<VoteData>[] | SignedData<PollMetadata> | undefined;
} }
export interface SignedData<T> { export interface SignedData<T> {

136
package-lock.json generated
View File

@@ -7,7 +7,6 @@
"name": "p2p-poll", "name": "p2p-poll",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@types/node": "^25.5.2",
"nuxt": "^4.1.3", "nuxt": "^4.1.3",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"vue": "^3.5.30", "vue": "^3.5.30",
@@ -1623,9 +1622,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1642,9 +1638,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1661,9 +1654,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1680,9 +1670,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1699,9 +1686,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1718,9 +1702,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1737,9 +1718,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1756,9 +1734,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1967,9 +1942,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1986,9 +1958,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2005,9 +1974,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2024,9 +1990,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2043,9 +2006,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2062,9 +2022,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2081,9 +2038,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2100,9 +2054,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2320,9 +2271,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2339,9 +2287,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2358,9 +2303,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2377,9 +2319,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2396,9 +2335,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2415,9 +2351,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2434,9 +2367,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2453,9 +2383,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2667,9 +2594,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2690,9 +2614,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2713,9 +2634,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2736,9 +2654,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2759,9 +2674,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2782,9 +2694,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3191,9 +3100,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3207,9 +3113,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3223,9 +3126,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3239,9 +3139,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3255,9 +3152,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3271,9 +3165,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3287,9 +3178,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3303,9 +3191,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3319,9 +3204,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3335,9 +3217,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3351,9 +3230,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3367,9 +3243,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3383,9 +3256,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3521,6 +3391,8 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz",
"integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
@@ -9004,7 +8876,9 @@
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/unenv": { "node_modules/unenv": {
"version": "2.0.0-rc.24", "version": "2.0.0-rc.24",

View File

@@ -15,8 +15,25 @@ export default defineEventHandler(async (event) => {
// GET: Fetch the saved Yjs document state // GET: Fetch the saved Yjs document state
if (method === 'GET') { if (method === 'GET') {
const data = await storage.getItem(`poll:${pollId}`); const data = await storage.getItem(`poll:${pollId}`);
// Return the array of numbers (or null if it doesn't exist yet)
return { update: data || null }; // Fetch all user public keys to include in response
const userStorage = useStorage('users');
const publicKeys: Record<string, string> = {};
// Get all user keys from storage
const keys = await userStorage.getKeys();
for (const key of keys) {
if (key.startsWith('user:')) {
const userId = key.replace('user:', '');
const publicKey = await userStorage.getItem(key);
if (publicKey) {
publicKeys[userId] = String(publicKey);
}
}
}
// Return the array of numbers (or null if it doesn't exist yet) along with public keys
return { update: data || null, publicKeys };
} }
// POST: Save a new Yjs document state // POST: Save a new Yjs document state
@@ -41,7 +58,16 @@ export default defineEventHandler(async (event) => {
for (let i = votes.length-1; i >= 0 ; i--) { for (let i = votes.length-1; i >= 0 ; i--) {
const userStorage = useStorage('users'); const userStorage = useStorage('users');
const votePubKeyString = await userStorage.getItem(`user:${votes[i]?.data.userid}`); const votePubKeyString = await userStorage.getItem(`user:${votes[i]?.data.userid}`);
//console.log("Using public key: "+votePubKeyString)
// Check if public key exists for this user
if (!votePubKeyString) {
console.error(`Error! No public key found for user: ${votes[i]?.data.userid}`);
throw createError({
statusCode: 400,
statusMessage: `Vote from unknown user: ${votes[i]?.data.userid}. User must be registered to vote.`
});
}
const votePubKey = await stringToCryptoKey(String(votePubKeyString),'public') const votePubKey = await stringToCryptoKey(String(votePubKeyString),'public')
const isValid = await verifyChainedVote(votes, i,votePubKey); const isValid = await verifyChainedVote(votes, i,votePubKey);
if(!isValid){ if(!isValid){

View File

@@ -1,4 +1,27 @@
// server/api/users/[id].ts // server/api/users/[id].ts
// Simple in-memory rate limiter
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT_WINDOW = 60000; // 1 minute
const RATE_LIMIT_MAX = 10; // 10 requests per minute per admin
function checkRateLimit(adminToken: string): boolean {
const now = Date.now();
const limit = rateLimitMap.get(adminToken);
if (!limit || now > limit.resetTime) {
rateLimitMap.set(adminToken, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
return true;
}
if (limit.count >= RATE_LIMIT_MAX) {
return false;
}
limit.count++;
return true;
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const method = event.node.req.method; const method = event.node.req.method;
const userId = getRouterParam(event, 'id'); const userId = getRouterParam(event, 'id');
@@ -20,6 +43,24 @@ export default defineEventHandler(async (event) => {
// POST: Save a new Yjs document state // POST: Save a new Yjs document state
if (method === 'POST') { if (method === 'POST') {
// Check for authentication
const authHeader = getHeader(event, 'authorization');
const adminApiKey = process.env.ADMIN_API_KEY || 'default-admin-key-change-in-production';
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({ statusCode: 401, statusMessage: 'Authorization header required' });
}
const token = authHeader.replace('Bearer ', '');
if (token !== adminApiKey) {
throw createError({ statusCode: 403, statusMessage: 'Invalid or expired token' });
}
// Check rate limiting
if (!checkRateLimit(token)) {
throw createError({ statusCode: 429, statusMessage: 'Rate limit exceeded. Try again later.' });
}
const body = await readBody(event); const body = await readBody(event);
if (body.public_key) { if (body.public_key) {

View File

@@ -33,8 +33,7 @@ export const verifyChainedVote = async (
const voteToVerify = voteData[index]; const voteToVerify = voteData[index];
console.log("Verifying vote: " + voteToVerify) console.log("Verifying vote: " + voteToVerify)
if(voteToVerify) { if(voteToVerify) {
// 1. Reconstruct the exact data state the user signed
// We need the array exactly as it was when they pushed their vote
const historicalState = voteData.slice(0, index + 1).map((v, i) => { const historicalState = voteData.slice(0, index + 1).map((v, i) => {
if (i === index) { if (i === index) {
// For the current vote, the signature must be empty string // For the current vote, the signature must be empty string
@@ -65,7 +64,11 @@ export const verifyChainedVote = async (
*/ */
export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'private'): Promise<CryptoKey> => { export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'private'): Promise<CryptoKey> => {
// 1. Convert Base64 string to a Uint8Array (binary) // 1. Convert Base64 string to a Uint8Array (binary)
const bytes = Buffer.from(keyStr, 'base64'); const binaryString = Buffer.from(keyStr, 'base64').toString('binary');
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// 2. Identify the format based on the key type // 2. Identify the format based on the key type
// Public keys usually use 'spki', Private keys use 'pkcs8' // Public keys usually use 'spki', Private keys use 'pkcs8'
@@ -75,7 +78,7 @@ export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'privat
// 3. Import the key // 3. Import the key
return await getCrypto().subtle.importKey( return await getCrypto().subtle.importKey(
format, format,
bytes, bytes.buffer,
{ {
name: "RSASSA-PKCS1-v1_5", name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256", hash: "SHA-256",