add vote uniqueness, public key caching, relative poll timeframe, auth/rate limiting, modern UI styling, and error handling

This commit is contained in:
2026-04-20 11:15:52 +01:00
parent f4d6a97abe
commit 07d40b3be8
56 changed files with 11413 additions and 8746 deletions

308
app/app.vue Normal file
View File

@@ -0,0 +1,308 @@
<style>
/* Modern dark theme with glassmorphism */
body {
font-family: system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
color: #fff;
margin: 0;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 2rem;
}
header {
margin-bottom: 2rem;
text-align: left;
}
h1 {
margin: 0 0 0.5rem 0;
font-size: 2.5rem;
font-weight: bold;
color: #fff;
}
h2 {
margin: 0.5rem 0;
color: #fff;
}
input {
flex-grow: 1;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
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%);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
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);
}
.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);
}
.connectionFailed {
color: #ff6b6b;
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 107, 107, 0.5);
}
.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);
width: 100%;
max-width: 600px;
}
.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);
}
/* Hide the actual file input */
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">
<header>
<h1 @click="activePollId = null" style="cursor:pointer">P2P Polling App 🗳</h1>
<div class="status">
<button v-if="activePollId" @click="activePollId = null" class="back-btn"> Back To List</button>
<span :class="{ 'connected': isConnected }">
{{ isConnected ? 'Synced' : 'Waiting for other Peers...' }}
</span>
<span> | Peers online: {{ connectedPeers }}</span>
<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 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>
<main>
<PollList v-if="!activePollId" :userid="user?.userid" @select-poll="selectPoll" />
<Poll v-else :activePollId="activePollId" :userid="user?.userid" :poll-data="pollData" :addOption="addOption" :vote="vote"/>
</main>
</div>
</template>
<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);
const { pollData, isConnected, connectionAttempFailed, connectedPeers, addOption, vote } = usePoll(activePollId,user);
const selectPoll = (id: string) => {
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);
// 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: 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);
}
};
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", "").replace("_public", "");
// 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,
};
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);
}
}
};
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>

119
app/components/Poll.vue Normal file
View File

@@ -0,0 +1,119 @@
<style scoped>
.poll-list {
list-style: none;
padding: 0;
margin: 0;
}
.poll-item {
display: flex;
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;
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;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: bold;
margin-bottom: 1rem;
}
.add-option-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.option-name {
font-weight: 500;
color: #fff;
}
.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-btn:disabled,
.vote-btn[disabled] {
background: rgba(136, 136, 136, 0.5);
cursor: not-allowed;
}
.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);
}
</style>
<template>
<div>
<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>
<form @submit.prevent="handleAddNewOption" class="add-option-form" v-if="userid">
<input v-model="newOption" placeholder="Enter a new poll option..." required />
<button type="submit">Add Option</button>
</form>
<ul class="poll-list">
<li v-for="(votes, optionName) in pollData" :key="optionName" class="poll-item">
<span class="option-name">{{ optionName }}</span>
<div class="vote-section">
<span class="vote-count">{{ votes.length }} {{ votes.length === 1 ? 'vote' : 'votes' }}</span>
<button @click="vote(String(optionName))" class="vote-btn" :disabled="userid==undefined || voted(votes)">+1</button>
</div>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import type { PollProps, SignedData, VoteData } from '@/utils/types'
const props = defineProps<PollProps>()
const newOption = ref('');
const handleAddNewOption = () => {
props.addOption(newOption.value);
newOption.value = '';
};
const voted = (votes: SignedData<VoteData>[]) => {
for(let vote of votes){
if(vote.data.userid == props.userid){
return true;
}
}
return false;
}
</script>

View File

@@ -0,0 +1,75 @@
<style scoped>
.poll-list { margin-top: 1rem; }
.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; }
.poll-links { list-style: none; padding: 0; }
.poll-link-btn {
width: 100%;
text-align: left;
background: rgba(255, 255, 255, 0.1);
color: #fff;
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;
}
</style>
<template>
<div class="poll-list">
<h3>Available Polls</h3>
<ul v-if="polls && polls.length > 0" class="poll-links">
<li v-for="id in polls" :key="id">
<button class="poll-link-btn" @click="$emit('select-poll', id)">
{{ id }} <span></span>
</button>
</li>
</ul>
<p v-else class="empty-state">No polls found. Create the first one!</p>
<div class="create-poll" v-if="userid !== undefined">
<input
v-model="newPollId"
placeholder="Enter new poll name..."
@keyup.enter="createPoll"
/>
<button @click="createPoll">Create & Join</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { PollListProps } from '@/utils/types'
const props = defineProps<PollListProps>()
const newPollId = ref('');
const polls = ref<string[]>([]);
// Fetch existing polls on mount
const fetchPolls = async () => {
const data = await $fetch<{ polls: string[] }>('/api/polls');
polls.value = data.polls;
};
const createPoll = () => {
const id = newPollId.value.trim().toLowerCase().replace(/\s+/g, '-');
if (id) {
// In a real app, you might want to POST to create it first,
// but here we just navigate to it and let usePoll handle the save.
emit('select-poll', id);
}
};
const emit = defineEmits(['select-poll']);
onMounted(fetchPolls);
</script>

178
app/composables/usePoll.ts Normal file
View File

@@ -0,0 +1,178 @@
// 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>({});
const isConnected = ref(false);
const connectionAttempFailed = ref(false);
const connectedPeers = ref(1);
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) => {
cleanup(); // Clear previous session
ydoc = new Y.Doc();
// 1. Fetch Snapshot from Nuxt API
try {
const response = await $fetch<{ update: number[] | null, publicKeys: Record<string, string> }>(`/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));
}
} catch (err) {
console.error('Persistence fetch failed', err);
}
yMap = ydoc.getMap<SignedData<VoteData>[]>('shared-poll');
// 2. Local State Sync
yMap.observe(async () => {
await performUpdateAndVerify();
saveStateToServer(id);
});
await performUpdateAndVerify();
// 3. P2P Connection
const { WebrtcProvider } = await import('y-webrtc');
provider = new WebrtcProvider(`nuxt-p2p-${id}`, ydoc, {
signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"]
});
provider.on('synced', (arg: {synced: boolean}) => {
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) => {
if (!ydoc) return;
const stateUpdate = Y.encodeStateAsUpdate(ydoc);
try {
await $fetch(`/api/polls/${id}`, {
method: 'POST',
body: { update: Array.from(stateUpdate) }
});
} 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(pollId, (newId) => {
if (newId && import.meta.client) {
initPoll(newId);
} else {
cleanup();
}
}, { immediate: true });
onUnmounted(cleanup);
const addOption = (optionName: string) => {
if (yMap && !yMap.has(optionName)) yMap.set(optionName, []);
};
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);
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) {
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) || [])];
// 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,
timestamp: new Date().toISOString()
}
var newVote : SignedData<VoteData> = {
data: unsignedVoteData,
signature: "",
}
voteData?.push(newVote)
const signature = await signVote(voteData,currentUser.private_key);
newVote.signature=signature
yMap?.set(optionName, voteData);
}
}
};
return { pollData, isConnected, connectionAttempFailed, connectedPeers, addOption, vote };
};

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

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

211
app/utils/crypto.ts Normal file
View File

@@ -0,0 +1,211 @@
// utils/crypto.ts
import type { SignedData, VoteData, PollMetadata } from "./types";
export const generateUserKeyPair = async () => {
return await window.crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]), // 65537
hash: "SHA-256",
},
true, // extractable
["sign", "verify"]
);
};
export const signVote = async (data: any, privateKey: CryptoKey) => {
const encoder = new TextEncoder();
const encodedData = encoder.encode(JSON.stringify(data));
const signature = await window.crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
privateKey,
encodedData
);
// Convert to Base64 or Hex to store in Yjs easily
return btoa(String.fromCharCode(...new Uint8Array(signature)));
};
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 window.crypto.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,
publicKeysCache?: Record<string, CryptoKey>,
pollMetadata?: PollMetadata
) => {
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
// 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. 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 {
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.")
// 4. 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>[],
publicKeysCache?: Record<string, CryptoKey>,
pollMetadata?: PollMetadata
) => {
console.log("verifying votes for option ",votes);
for (let i = votes.length-1; i >= 0 ; i--) {
const isValid = await verifyChainedVote(votes, i, publicKeysCache, pollMetadata);
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);
});
};

43
app/utils/types.ts Normal file
View File

@@ -0,0 +1,43 @@
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 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> {
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
}