+ create user with public/private key

+ sign and verify votes and prevent unverified updates
This commit is contained in:
2026-04-04 22:36:17 +02:00
parent b5cb0e83e3
commit bc5e2eead8
15 changed files with 10672 additions and 52 deletions

View File

@@ -1,4 +1,56 @@
# P2P Poll App # 🗳️ P2P Verified Polling App
A decentralized, real-time polling application built with **Nuxt 3**, **Yjs**, and **WebRTC**. This app allows users to create and participate in polls where every vote is cryptographically signed and verified peer-to-peer, ensuring data integrity without a central authority "owning" the results.
---
## 🌟 Key Features
* **Serverless Real-time Sync:** Uses **Yjs** (CRDTs) and **WebRTC** to sync poll data directly between browsers. No database is required for live updates.
* **Persistence with Nitro:** While the logic is P2P, the **Nuxt/Nitro** backend provides a "Snapshot" service to ensure polls persist even after all peers go offline.
* **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.
---
## ⚙️ How It Works
### 1. Identity Creation
When a new user is created, the system generates a unique **UUID (User ID)** and an **RSA Key Pair**. The user is prompted to save their **Private Key** as a `.pem` file, named after their User ID (e.g., `550e8400-e29b.pem`). This file acts as their "Passport"—it is never uploaded to the server and must be kept secure by the user.
### 2. Authentication
Upon returning to the app, users load their local `.pem` file. The application extracts the Private Key for signing and the UUID for identification. No passwords or central servers are involved in this local-first login process.
### 3. Joining a Poll
When a user joins a poll, the app fetches the latest binary snapshot from the server to populate a local **Y.Doc**. This ensures the user sees the current state immediately, even before connecting to other peers.
### 4. The P2P Mesh
The app establishes connections to other active voters via a WebRTC signaling server. Any changes made to the poll (adding options or voting) are broadcasted instantly to all peers using Conflict-free Replicated Data Types (CRDTs) to prevent sync conflicts.
### 5. Casting a Signed Vote
To ensure security, the voting process follows a strict cryptographic chain:
* The app captures the current list of votes.
* It appends the new vote data (User ID + Timestamp).
* It signs the **entire array** (the previous history + the new vote) using the user's RSA private key.
* The signed update is merged into the shared Yjs Map and broadcasted.
### 6. Distributed Verification
Whenever a peer receives a new update, they fetch the voter's **Public Key** from the API. They then verify that the signature matches the current state of the poll history. If a signature is invalid or the history has been tampered with, the vote is rejected by the peer's local state.
---
## 🛠️ Tech Stack
* **Framework:** [Nuxt 3](https://nuxt.com/) (Vue 3 + TypeScript)
* **Conflict-Free Replicated Data Types (CRDT):** [Yjs](https://yjs.dev/)
* **P2P Transport:** `y-webrtc`
* **Security:** Web Crypto API (SubtleCrypto)
* **Backend/Storage:** Nitro (Nuxt's server engine) with filesystem storage drivers
# AI Disclaimer
This App was developed with the assistance of AI.
# Nuxt Minimal Starter # Nuxt Minimal Starter

View File

@@ -25,7 +25,8 @@ input {
font-size: 1rem; font-size: 1rem;
} }
button { button,
.button {
background: #3b82f6; background: #3b82f6;
color: white; color: white;
border: none; border: none;
@@ -36,7 +37,8 @@ button {
transition: background 0.2s; transition: background 0.2s;
} }
button:hover { background: #2563eb; } button:hover,
.button:hover { background: #2563eb; }
.status { .status {
font-size: 0.85rem; font-size: 0.85rem;
@@ -61,6 +63,12 @@ button:hover { background: #2563eb; }
font-size: 0.7rem; font-size: 0.7rem;
background: #64748b; background: #64748b;
} }
/* Hide the actual file input */
input[type="file"] {
display: none;
}
</style> </style>
<template> <template>
<div class="poll-container"> <div class="poll-container">
@@ -72,23 +80,96 @@ button:hover { background: #2563eb; }
{{ isConnected ? 'Synced' : 'Waiting for other Peers...' }} {{ isConnected ? 'Synced' : 'Waiting for other Peers...' }}
</span> </span>
<span> | Peers online: {{ connectedPeers }}</span> <span> | Peers online: {{ connectedPeers }}</span>
</div>
<h2 v-if="connectionAttempFailed" class="connectionFailed"> Connection to Signaling Server Failed!</h2> <h2 v-if="connectionAttempFailed" class="connectionFailed"> Connection to Signaling Server Failed!</h2>
<div v-if="user===null" style="margin-top: 10px;">
<button @click="createUser">Create New User</button>
Or
<label title="Select Key File">
<span class="button">Login</span>
<input
type="file"
accept=".pem"
@change="loadUser"
/>
</label>
</div>
</div>
</header> </header>
<main> <main>
<PollList v-if="!activePollId" @select-poll="selectPoll" /> <PollList v-if="!activePollId" :userid="user?.userid" @select-poll="selectPoll" />
<Poll v-else :activePollId="activePollId" :pollData="pollData" :addOption="addOption" :vote="vote"/> <Poll v-else :activePollId="activePollId" :userid="user?.userid" :poll-data="pollData" :addOption="addOption" :vote="vote"/>
</main> </main>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { v4 as uuidv4 } from 'uuid';
const activePollId = ref<string | null>(null); const activePollId = ref<string | null>(null);
const user = shallowRef<UserData | null>(null);
const { pollData, isConnected, connectionAttempFailed, connectedPeers, addOption, vote } = usePoll(activePollId); const { pollData, isConnected, connectionAttempFailed, connectedPeers, addOption, vote } = usePoll(activePollId,user);
const selectPoll = (id: string) => { const selectPoll = (id: string) => {
activePollId.value = id; activePollId.value = id;
}; };
const createUser = async () => {
try {
const keypair : CryptoKeyPair = await generateUserKeyPair();
console.log('keypair:', keypair);
const uuid = uuidv4();
user.value = {
userid: uuid,
private_key: keypair.privateKey,
public_key: keypair.publicKey,
};
const prvKeyString = await exportPrivateKey(keypair.privateKey);
await savePrivateKeyToFile(prvKeyString,uuid+".pem")
const pubKeyString = await exportPublicKey(keypair.publicKey);
await $fetch(`/api/users/${uuid}`, {
method: 'POST',
body: { public_key: pubKeyString }
});
} catch (err) {
user.value = null
console.error("Failed to create new User!", err);
}
};
const loadUser = async (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
try {
const content = await file.text();
console.log("File loaded: ");
if (file.name && content) {
try {
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, "");
const key = await stringToCryptoKey(pkBase64, "private");
user.value = {
userid: uuid,
private_key: key,
public_key: undefined, // Note: You might need to import a pub key too!
};
console.log("Login successful for:", uuid);
} catch (err) {
console.error("Crypto Import Error:", err);
alert("The file content is not a valid Private Key.");
}
}
} catch (e) {
console.error("Failed to read file", e);
}
}
};
</script> </script>

View File

@@ -44,7 +44,7 @@
<div> <div>
<h2 class="poll-title">Poll: {{ activePollId }}</h2> <h2 class="poll-title">Poll: {{ activePollId }}</h2>
<p v-if="Object.keys(pollData).length==0">Note: Add at least one Option to save the Poll.</p> <p v-if="Object.keys(pollData).length==0">Note: Add at least one Option to save the Poll.</p>
<form @submit.prevent="handleAddNewOption" class="add-option-form"> <form @submit.prevent="handleAddNewOption" class="add-option-form" v-if="userid">
<input v-model="newOption" placeholder="Enter a new poll option..." required /> <input v-model="newOption" placeholder="Enter a new poll option..." required />
<button type="submit">Add Option</button> <button type="submit">Add Option</button>
</form> </form>
@@ -54,7 +54,7 @@
<span class="option-name">{{ optionName }}</span> <span class="option-name">{{ optionName }}</span>
<div class="vote-section"> <div class="vote-section">
<span class="vote-count">{{ votes.length }} {{ votes.length === 1 ? 'vote' : 'votes' }}</span> <span class="vote-count">{{ votes.length }} {{ votes.length === 1 ? 'vote' : 'votes' }}</span>
<button @click="vote(String(optionName),String(userGuid))" class="vote-btn" :disabled="voted(votes)">+1</button> <button @click="vote(String(optionName))" class="vote-btn" :disabled="userid==undefined || voted(votes)">+1</button>
</div> </div>
</li> </li>
</ul> </ul>
@@ -71,11 +71,10 @@
newOption.value = ''; newOption.value = '';
}; };
const userGuid = useCookie('user_guid');
const voted = (votes: SignedData<VoteData>[]) => { const voted = (votes: SignedData<VoteData>[]) => {
for(let vote of votes){ for(let vote of votes){
if(vote.data.userid == userGuid.value){ if(vote.data.userid == props.userid){
return true; return true;
} }
} }

View File

@@ -27,7 +27,7 @@
</li> </li>
</ul> </ul>
<p v-else class="empty-state">No polls found. Create the first one!</p> <p v-else class="empty-state">No polls found. Create the first one!</p>
<div class="create-poll"> <div class="create-poll" v-if="userid !== undefined">
<input <input
v-model="newPollId" v-model="newPollId"
placeholder="Enter new poll name..." placeholder="Enter new poll name..."
@@ -39,6 +39,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { PollListProps } from '@/utils/types'
const props = defineProps<PollListProps>()
const newPollId = ref(''); const newPollId = ref('');
const polls = ref<string[]>([]); const polls = ref<string[]>([]);

View File

@@ -2,7 +2,7 @@
import { ref, watch, onUnmounted } from 'vue'; import { ref, watch, onUnmounted } from 'vue';
import * as Y from 'yjs'; import * as Y from 'yjs';
export const usePoll = (pollId: Ref<string | null>) => { export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>) => {
const pollData = ref<PollData>({}); const pollData = ref<PollData>({});
const isConnected = ref(false); const isConnected = ref(false);
const connectionAttempFailed = ref(false); const connectionAttempFailed = ref(false);
@@ -26,7 +26,10 @@ export const usePoll = (pollId: Ref<string | 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}`); const response = await $fetch<{ update: number[] | null }>(`/api/polls/${id}`).catch((e) => {
console.error("Failed to get poll: " + id,e)
});
//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));
} }
@@ -37,20 +40,28 @@ export const usePoll = (pollId: Ref<string | null>) => {
yMap = ydoc.getMap<SignedData<VoteData>[]>('shared-poll'); yMap = ydoc.getMap<SignedData<VoteData>[]>('shared-poll');
// 2. Local State Sync // 2. Local State Sync
yMap.observe(() => { yMap.observe(async () => {
pollData.value = yMap!.toJSON(); await performUpdateAndVerify();
saveStateToServer(id); saveStateToServer(id);
}); });
pollData.value = yMap.toJSON(); await performUpdateAndVerify();
// 3. P2P Connection // 3. P2P Connection
const { WebrtcProvider } = await import('y-webrtc'); const { WebrtcProvider } = await import('y-webrtc');
provider = new WebrtcProvider(`nuxt-p2p-${id}`, ydoc, { provider = new WebrtcProvider(`nuxt-p2p-${id}`, ydoc, {
signaling: ["ws:localhost:4444", "ws:lynxpi.ddns.net:4444"] signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"]
}); });
provider.on('synced', (arg: {synced: boolean}) => isConnected.value = arg.synced); provider.on('synced', (arg: {synced: boolean}) => {
provider.on('peers', (data: any) => connectedPeers.value = data.webrtcPeers.length + 1); isConnected.value = arg.synced;
console.log('Connection synced:', arg.synced) // "connected" or "disconnected"
});
provider.on('status', (event: { connected: boolean }) => {
console.log('Connection status:', event.connected) // "connected" or "disconnected"
})
provider.on('peers', (data: any) => {
connectedPeers.value = data.webrtcPeers.length + 1
});
}; };
const saveStateToServer = async (id: string) => { const saveStateToServer = async (id: string) => {
@@ -59,7 +70,9 @@ export const usePoll = (pollId: Ref<string | null>) => {
await $fetch(`/api/polls/${id}`, { await $fetch(`/api/polls/${id}`, {
method: 'POST', method: 'POST',
body: { update: Array.from(stateUpdate) } body: { update: Array.from(stateUpdate) }
}).catch(() => {}); }).catch((e) => {
console.error("Failed to update poll",e)
});
}; };
// 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)
@@ -77,12 +90,29 @@ export const usePoll = (pollId: Ref<string | null>) => {
if (yMap && !yMap.has(optionName)) yMap.set(optionName, []); if (yMap && !yMap.has(optionName)) yMap.set(optionName, []);
}; };
const vote = (optionName: string, uuid: string) => { const performUpdateAndVerify = async () => {
if (yMap?.has(optionName)) { const pollDataUpdate = yMap!.toJSON();
var voteData : SignedData<VoteData>[] | undefined = yMap.get(optionName) console.log("Poll Data Update: ", pollDataUpdate)
if(voteData != undefined){ for(var option in pollDataUpdate){
console.log("verifying votes for option: " + option);
const votes = pollDataUpdate[option] || [];
const verified = await verifyAllVotesForOption(votes);
if(!verified){
console.error("Failed to verify option: "+option)
return;
}
}
console.log("All options verified! :)")
pollData.value = pollDataUpdate
}
const vote = async (optionName: string) => {
const currentUser = user.value;
if (currentUser != undefined && yMap?.has(optionName)) {
const voteData = [...(yMap.get(optionName) || [])];
if(voteData != undefined && currentUser.private_key){
var unsignedVoteData : VoteData = { var unsignedVoteData : VoteData = {
userid: uuid, userid: currentUser.userid,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
} }
var newVote : SignedData<VoteData> = { var newVote : SignedData<VoteData> = {
@@ -90,7 +120,9 @@ export const usePoll = (pollId: Ref<string | null>) => {
signature: "", signature: "",
} }
voteData?.push(newVote) voteData?.push(newVote)
yMap.set(optionName, voteData); const signature = await signVote(voteData,currentUser.private_key);
newVote.signature=signature
yMap?.set(optionName, voteData);
} }
} }
}; };

2
app/composables/user.ts Normal file
View File

@@ -0,0 +1,2 @@
export const user = (user: Ref<UserData | null>) => {
}

View File

@@ -40,3 +40,148 @@ export const verifyVote = async (data: any, signatureStr: string, publicKey: Cry
encodedData encodedData
); );
}; };
/**
* Verifies a specific vote within an array of votes by
* reconstructing the "signed state" at that point in time.
*/
export const verifyChainedVote = async (
voteData: SignedData<VoteData>[],
index: number
) => {
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
// because it wasn't signed yet when passed to signVote
return { ...v, signature: "" };
}
return v;
});
try {
// 2. Fetch public key
const response = await $fetch<{ public_key: string }>(`/api/users/${voteToVerify.data.userid}`);
console.log("Got key: ",response)
const pubKey = await stringToCryptoKey(response.public_key, 'public');
console.log("Using pubKey to verify Vote.")
// 3. Verify: Does this historicalState match the signature?
return await verifyVote(historicalState, voteToVerify.signature, pubKey);
} catch (err) {
console.error("Verification failed")
console.error(err);
return false;
}
}
console.error("Vote is undefined or null");
return false;
};
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);
if(!isValid){
console.error("Error! Invalid Vote at: " + i,votes)
return false;
}
}
return true;
};
// Helper to convert ArrayBuffer to Base64 string
const bufferToBase64 = (buf: ArrayBuffer) =>
window.btoa(String.fromCharCode(...new Uint8Array(buf)));
export const exportPublicKey = async (key: CryptoKey) => {
// Export Public Key
const exportedPublic = await window.crypto.subtle.exportKey("spki", key);
const publicKeyString = bufferToBase64(exportedPublic);
return publicKeyString;
};
export const exportPrivateKey = async (key: CryptoKey) => {
// Export Private Key
const exportedPrivate = await window.crypto.subtle.exportKey("pkcs8", key);
const privateKeyString = bufferToBase64(exportedPrivate);
return privateKeyString;
};
/**
* Converts a Base64 string back into a usable CryptoKey object
* @param keyStr The Base64 string (without PEM headers)
* @param type 'public' or 'private'
*/
export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'private'): Promise<CryptoKey> => {
// 1. Convert Base64 string to a Uint8Array (binary)
const binaryString = window.atob(keyStr);
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
// Public keys usually use 'spki', Private keys use 'pkcs8'
const format = type === 'public' ? 'spki' : 'pkcs8';
const usages: KeyUsage[] = type === 'public' ? ['verify'] : ['sign'];
// 3. Import the key
return await window.crypto.subtle.importKey(
format,
bytes.buffer,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true, // extractable (set to false if you want to lock it in memory)
usages
);
};
export const savePrivateKeyToFile = (privateKeyStr: string, filename: string) => {
// Optional: Wrap in PEM headers for standard formatting
const pemHeader = "-----BEGIN PRIVATE KEY-----\n";
const pemFooter = "\n-----END PRIVATE KEY-----";
const fileContent = pemHeader + privateKeyStr + pemFooter;
const blob = new Blob([fileContent], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
export const loadPrivateKeyFromFile = async (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
// Clean up the string by removing PEM headers and newlines
const cleanKey = content
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace(/\s+/g, ""); // Removes all whitespace/newlines
resolve(cleanKey);
};
reader.onerror = () => reject("Error reading file");
reader.readAsText(file);
});
};

View File

@@ -1,8 +1,13 @@
export interface PollProps { export interface PollProps {
userid: string | undefined,
activePollId: string, activePollId: string,
pollData: PollData, pollData: PollData,
addOption: (name: string) => void, addOption: (name: string) => void,
vote: (optionName: string,uuid: string) => void vote: (optionName: string) => void
}
export interface PollListProps {
userid: string | undefined,
} }
export interface PollData extends Record<string, SignedData<VoteData>[]> { export interface PollData extends Record<string, SignedData<VoteData>[]> {
@@ -23,3 +28,9 @@ export interface OptionData {
timestamp: string, timestamp: string,
optionName: string optionName: string
} }
export interface UserData {
userid: string,
private_key: CryptoKey | undefined,
public_key: CryptoKey | undefined
}

View File

@@ -13,6 +13,10 @@ export default defineNuxtConfig({
polls: { polls: {
driver: 'fs', driver: 'fs',
base: './.data/polls' base: './.data/polls'
},
users: {
driver: 'fs',
base: './.data/users'
} }
} }
} }

10118
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import * as Y from 'yjs';
// server/api/polls/[id].ts // server/api/polls/[id].ts
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const method = event.node.req.method; const method = event.node.req.method;
@@ -23,6 +24,40 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event); const body = await readBody(event);
if (body.update && Array.isArray(body.update)) { if (body.update && Array.isArray(body.update)) {
// create a temp Y.Doc to encode the Data
const tempDoc = new Y.Doc();
Y.applyUpdate(tempDoc, new Uint8Array(body.update));
const yMap = tempDoc.getMap('shared-poll');
const pollData = yMap.toJSON();
// verify pollData
for(var option in pollData){
const votes = pollData[option] || [];
var pubKeys: CryptoKey[] = [];
const verifyAllVotesForOption = async (votes: SignedData<VoteData>[]) => {
console.log("verifying votes for option " + option,votes);
// check last votes first. if there is something wrong, its likely in the last vote.
for (let i = votes.length-1; i >= 0 ; i--) {
const userStorage = useStorage('users');
const votePubKeyString = await userStorage.getItem(`user:${votes[i]?.data.userid}`);
//console.log("Using public key: "+votePubKeyString)
const votePubKey = await stringToCryptoKey(String(votePubKeyString),'public')
const isValid = await verifyChainedVote(votes, i,votePubKey);
if(!isValid){
console.error("Error! Invalid Vote at: " + i,votes)
return false;
}
}
return true;
};
const verified = await verifyAllVotesForOption(votes);
if(!verified){
console.error("Failed to verify option: "+option)
throw createError({ statusCode: 400, statusMessage: 'PollData contains unverifyable content!' });
}
}
// Save the binary update (sent as an array of numbers) to storage // Save the binary update (sent as an array of numbers) to storage
await storage.setItem(`poll:${pollId}`, body.update); await storage.setItem(`poll:${pollId}`, body.update);
return { success: true }; return { success: true };

41
server/api/users/[id].ts Normal file
View File

@@ -0,0 +1,41 @@
// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
const method = event.node.req.method;
const userId = getRouterParam(event, 'id');
// We use Nitro's built-in storage.
// 'polls' is the storage namespace.
const storage = useStorage('users');
if (!userId) {
throw createError({ statusCode: 400, statusMessage: 'User ID required' });
}
// GET: Fetch the saved Yjs document state
if (method === 'GET') {
const data = await storage.getItem(`user:${userId}`);
// Return the array of numbers (or null if it doesn't exist yet)
return { public_key: data };
}
// POST: Save a new Yjs document state
if (method === 'POST') {
const body = await readBody(event);
if (body.public_key) {
const data = await storage.getItem(`user:${userId}`);
if (data == undefined || data == null) {
// Save the binary update (sent as an array of numbers) to storage
await storage.setItem(`user:${userId}`, body.public_key);
console.log("New User created: " + userId)
console.log("Public Key: " + body.public_key);
return { success: true };
}
throw createError({ statusCode: 400, statusMessage: 'User already exists.' });
}
throw createError({ statusCode: 400, statusMessage: 'Invalid update payload' });
}
});

View File

@@ -1,24 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
export default defineEventHandler((event) => {
// 1. Check if the cookie already exists
const cookie = getCookie(event, 'user_guid');
// 2. If it doesn't exist, generate and set it
if (!cookie) {
const newUuid = uuidv4();
setCookie(event, 'user_guid', newUuid, {
maxAge: 60 * 60 * 24 * 7, // 1 week
path: '/',
// httpOnly: true, // Set to true if you DON'T need to read it in Vue/JS
sameSite: 'lax',
});
// 3. Inject it into the context so it's available
// to other server routes/plugins during this same request
event.context.userGuid = newUuid;
} else {
event.context.userGuid = cookie;
}
});

86
server/utils/crypto.ts Normal file
View File

@@ -0,0 +1,86 @@
import { SignedData, VoteData } from "./types";
/**
* Gets the WebCrypto API regardless of environment (Node vs Browser)
*/
const getCrypto = () => {
return (globalThis as any).crypto;
};
export const verifyVote = async (data: any, signatureStr: string, publicKey: CryptoKey) => {
const encoder = new TextEncoder();
const encodedData = encoder.encode(JSON.stringify(data));
// Convert Base64 back to Uint8Array
const signature = Uint8Array.from(atob(signatureStr), c => c.charCodeAt(0));
return await getCrypto().subtle.verify(
"RSASSA-PKCS1-v1_5",
publicKey,
signature,
encodedData
);
};
/**
* Verifies a specific vote within an array of votes by
* reconstructing the "signed state" at that point in time.
*/
export const verifyChainedVote = async (
voteData: SignedData<VoteData>[],
index: number,
pubKey: CryptoKey
) => {
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
// because it wasn't signed yet when passed to signVote
return { ...v, signature: "" };
}
return v;
});
try {
// 3. Verify: Does this historicalState match the signature?
return await verifyVote(historicalState, voteToVerify.signature, pubKey);
} catch (err) {
console.error("Verification failed")
console.error(err);
return false;
}
}
console.error("Vote is undefined or null");
return false;
};
/**
* Converts a Base64 string back into a usable CryptoKey object
* @param keyStr The Base64 string (without PEM headers)
* @param type 'public' or 'private'
*/
export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'private'): Promise<CryptoKey> => {
// 1. Convert Base64 string to a Uint8Array (binary)
const bytes = Buffer.from(keyStr, 'base64');
// 2. Identify the format based on the key type
// Public keys usually use 'spki', Private keys use 'pkcs8'
const format = type === 'public' ? 'spki' : 'pkcs8';
const usages: KeyUsage[] = type === 'public' ? ['verify'] : ['sign'];
// 3. Import the key
return await getCrypto().subtle.importKey(
format,
bytes,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true, // extractable (set to false if you want to lock it in memory)
usages
);
};

36
server/utils/types.ts Normal file
View File

@@ -0,0 +1,36 @@
export interface PollProps {
userid: string | undefined,
activePollId: string,
pollData: PollData,
addOption: (name: string) => void,
vote: (optionName: string) => void
}
export interface PollListProps {
userid: string | undefined,
}
export interface PollData extends Record<string, SignedData<VoteData>[]> {
}
export interface SignedData<T> {
data: T,
signature: string
}
export interface VoteData {
userid: string,
timestamp: string
}
export interface OptionData {
userid: string,
timestamp: string,
optionName: string
}
export interface UserData {
userid: string,
private_key: CryptoKey | undefined,
public_key: CryptoKey | undefined
}