Compare commits

..

2 Commits

Author SHA1 Message Date
bc5e2eead8 + create user with public/private key
+ sign and verify votes and prevent unverified updates
2026-04-04 22:36:17 +02:00
b5cb0e83e3 * init p2p polling app 2026-03-31 19:09:46 +02:00
13 changed files with 223 additions and 409 deletions

BIN
.DS_Store vendored

Binary file not shown.

24
.gitignore vendored
View File

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

View File

@@ -11,12 +11,6 @@ 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.
* **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.
* **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,11 +1,10 @@
<style>
/* Modern dark theme with glassmorphism */
/* Basic styling to make it look clean */
body {
font-family: system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
color: #fff;
background-color: #f4f4f9;
color: #333;
margin: 0;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 2rem;
@@ -13,94 +12,56 @@ body {
header {
margin-bottom: 2rem;
text-align: left;
text-align: center;
}
h1 {
margin: 0 0 0.5rem 0;
font-size: 2.5rem;
font-weight: bold;
color: #fff;
}
h2 {
margin: 0.5rem 0;
color: #fff;
}
h1 { margin: 0 0 0.5rem 0; }
input {
flex-grow: 1;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
border: 1px solid #ccc;
border-radius: 6px;
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 {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: #3b82f6;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
padding: 0.75rem 1rem;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
font-weight: bold;
transition: background 0.2s;
}
button:hover,
.button:hover {
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.button:hover { background: #2563eb; }
.status {
font-size: 0.9rem;
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);
font-size: 0.85rem;
color: #666;
}
.status .connected { color: #10b981; font-weight: bold; }
.connectionFailed {
color: #ff6b6b;
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 107, 107, 0.5);
}
.connectionFailed { color: #FF2525; font-weight: bold; }
.poll-container {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
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);
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
width: 100%;
max-width: 600px;
max-width: 500px;
}
.back-btn {
margin-left: 1rem;
padding: 0.5rem 1rem;
font-size: 0.85rem;
background: rgba(100, 116, 139, 0.8);
backdrop-filter: blur(10px);
}
.back-btn:hover {
background: rgba(100, 116, 139, 1);
padding: 0.2rem 0.5rem;
font-size: 0.7rem;
background: #64748b;
}
/* Hide the actual file input */
@@ -108,30 +69,6 @@ input[type="file"] {
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>
<template>
<div class="poll-container">
@@ -155,16 +92,6 @@ input[type="file"] {
@change="loadUser"
/>
</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>
</header>
@@ -178,7 +105,6 @@ input[type="file"] {
<script setup lang="ts">
import { v4 as uuidv4 } from 'uuid';
import { generateUserKeyPair, exportPrivateKey, savePrivateKeyToFile, exportPublicKey, stringToCryptoKey } from '~/utils/crypto';
const activePollId = ref<string | null>(null);
const user = shallowRef<UserData | null>(null);
@@ -202,32 +128,12 @@ input[type="file"] {
const prvKeyString = await exportPrivateKey(keypair.privateKey);
await savePrivateKeyToFile(prvKeyString,uuid+".pem")
const pubKeyString = await exportPublicKey(keypair.publicKey);
// Save public key to server
const pubKeyString = await exportPublicKey(keypair.publicKey);
await $fetch(`/api/users/${uuid}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ADMIN_API_KEY || 'default-admin-key-change-in-production'}`
},
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) {
user.value = null
console.error("Failed to create new User!", err);
@@ -243,7 +149,7 @@ input[type="file"] {
console.log("File loaded: ");
if (file.name && content) {
try {
const uuid = file.name.replace(".pem", "").replace("_public", "");
const uuid = file.name.replace(".pem", "");
// Standardize the string for the importer
const pkBase64 = content.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----/g, "").replace(/\s+/g, "");
@@ -252,7 +158,7 @@ input[type="file"] {
user.value = {
userid: uuid,
private_key: key,
public_key: undefined,
public_key: undefined, // Note: You might need to import a pub key too!
};
console.log("Login successful for:", uuid);
@@ -266,43 +172,4 @@ 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>

View File

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

View File

@@ -1,29 +1,18 @@
<style scoped>
.poll-list { margin-top: 1rem; }
.empty-state { text-align: center; color: rgba(255, 255, 255, 0.6); font-style: italic; }
.empty-state { text-align: center; color: #94a3b8; font-style: italic; }
.create-poll { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
.poll-links { list-style: none; padding: 0; }
.poll-link-btn {
width: 100%;
text-align: left;
background: rgba(255, 255, 255, 0.1);
color: #fff;
background: #f1f5f9;
color: #1e293b;
margin-bottom: 0.5rem;
display: flex;
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>
<template>

View File

@@ -1,8 +1,6 @@
// composables/usePoll.ts
import { ref, watch, onUnmounted } from 'vue';
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>) => {
const pollData = ref<PollData>({});
@@ -13,14 +11,12 @@ export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>)
let ydoc: Y.Doc | null = null;
let provider: any = null;
let yMap: Y.Map<SignedData<VoteData>[]> | null = null;
let publicKeysCache: Record<string, CryptoKey> = {};
const cleanup = () => {
if (provider) provider.disconnect();
if (ydoc) ydoc.destroy();
isConnected.value = false;
pollData.value = {};
publicKeysCache = {};
};
const initPoll = async (id: string) => {
@@ -30,22 +26,9 @@ export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>)
// 1. Fetch Snapshot from Nuxt API
try {
const response = await $fetch<{ update: number[] | null, publicKeys: Record<string, string> }>(`/api/polls/${id}`).catch((e) => {
const response = await $fetch<{ update: number[] | null }>(`/api/polls/${id}`).catch((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.
if (response?.update) {
Y.applyUpdate(ydoc, new Uint8Array(response.update));
@@ -84,21 +67,12 @@ export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>)
const saveStateToServer = async (id: string) => {
if (!ydoc) return;
const stateUpdate = Y.encodeStateAsUpdate(ydoc);
try {
await $fetch(`/api/polls/${id}`, {
method: 'POST',
body: { update: Array.from(stateUpdate) }
}).catch((e) => {
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)
@@ -119,18 +93,10 @@ export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>)
const performUpdateAndVerify = async () => {
const pollDataUpdate = yMap!.toJSON();
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){
// Skip metadata key when iterating over options
if (option === '_metadata') continue;
console.log("verifying votes for option: " + option);
const votes = pollDataUpdate[option] || [];
const verified = await verifyAllVotesForOption(votes, publicKeysCache, pollMetadata);
const verified = await verifyAllVotesForOption(votes);
if(!verified){
console.error("Failed to verify option: "+option)
return;
@@ -142,21 +108,8 @@ export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>)
const vote = async (optionName: string) => {
const currentUser = user.value;
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)) {
if (currentUser != undefined && yMap?.has(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){
var unsignedVoteData : VoteData = {
userid: currentUser.userid,

View File

@@ -1,6 +1,4 @@
// utils/crypto.ts
import type { SignedData, VoteData, PollMetadata } from "./types";
export const generateUserKeyPair = async () => {
return await window.crypto.subtle.generateKey(
{
@@ -49,24 +47,12 @@ export const verifyVote = async (data: any, signatureStr: string, publicKey: Cry
*/
export const verifyChainedVote = async (
voteData: SignedData<VoteData>[],
index: number,
publicKeysCache?: Record<string, CryptoKey>,
pollMetadata?: PollMetadata
index: number
) => {
const voteToVerify = voteData[index];
console.log("Verifying vote: " + voteToVerify)
if(voteToVerify) {
// 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
// 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) => {
if (i === index) {
@@ -78,19 +64,13 @@ export const verifyChainedVote = async (
});
try {
// 3. Get public key from cache or fetch from API
let pubKey: CryptoKey;
if (publicKeysCache && publicKeysCache[voteToVerify.data.userid]) {
pubKey = publicKeysCache[voteToVerify.data.userid];
console.log("Using cached public key for user:", voteToVerify.data.userid);
} else {
// 2. Fetch public key
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("Got key: ",response)
const pubKey = await stringToCryptoKey(response.public_key, 'public');
console.log("Using pubKey to verify Vote.")
// 4. Verify: Does this historicalState match the signature?
// 3. Verify: Does this historicalState match the signature?
return await verifyVote(historicalState, voteToVerify.signature, pubKey);
} catch (err) {
console.error("Verification failed")
@@ -102,14 +82,10 @@ export const verifyChainedVote = async (
return false;
};
export const verifyAllVotesForOption = async (
votes: SignedData<VoteData>[],
publicKeysCache?: Record<string, CryptoKey>,
pollMetadata?: PollMetadata
) => {
export const verifyAllVotesForOption = async (votes: SignedData<VoteData>[]) => {
console.log("verifying votes for option ",votes);
for (let i = votes.length-1; i >= 0 ; i--) {
const isValid = await verifyChainedVote(votes, i, publicKeysCache, pollMetadata);
const isValid = await verifyChainedVote(votes, i);
if(!isValid){
console.error("Error! Invalid Vote at: " + i,votes)
return false;

View File

@@ -10,14 +10,7 @@ export interface PollListProps {
userid: string | undefined,
}
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 PollData extends Record<string, SignedData<VoteData>[]> {
}
export interface SignedData<T> {

136
package-lock.json generated
View File

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

View File

@@ -15,25 +15,8 @@ export default defineEventHandler(async (event) => {
// GET: Fetch the saved Yjs document state
if (method === 'GET') {
const data = await storage.getItem(`poll:${pollId}`);
// 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 };
// Return the array of numbers (or null if it doesn't exist yet)
return { update: data || null };
}
// POST: Save a new Yjs document state
@@ -58,16 +41,7 @@ export default defineEventHandler(async (event) => {
for (let i = votes.length-1; i >= 0 ; i--) {
const userStorage = useStorage('users');
const votePubKeyString = await userStorage.getItem(`user:${votes[i]?.data.userid}`);
// 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.`
});
}
//console.log("Using public key: "+votePubKeyString)
const votePubKey = await stringToCryptoKey(String(votePubKeyString),'public')
const isValid = await verifyChainedVote(votes, i,votePubKey);
if(!isValid){

View File

@@ -1,27 +1,4 @@
// 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) => {
const method = event.node.req.method;
const userId = getRouterParam(event, 'id');
@@ -43,24 +20,6 @@ export default defineEventHandler(async (event) => {
// POST: Save a new Yjs document state
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);
if (body.public_key) {

View File

@@ -33,7 +33,8 @@ export const verifyChainedVote = async (
const voteToVerify = voteData[index];
console.log("Verifying vote: " + 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) => {
if (i === index) {
// For the current vote, the signature must be empty string
@@ -64,11 +65,7 @@ export const verifyChainedVote = async (
*/
export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'private'): Promise<CryptoKey> => {
// 1. Convert Base64 string to a Uint8Array (binary)
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);
}
const bytes = Buffer.from(keyStr, 'base64');
// 2. Identify the format based on the key type
// Public keys usually use 'spki', Private keys use 'pkcs8'
@@ -78,7 +75,7 @@ export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'privat
// 3. Import the key
return await getCrypto().subtle.importKey(
format,
bytes.buffer,
bytes,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",