fix: Correct ElGamal public key serialization and .gitignore Python lib paths

- Fix ElGamalEncryption to generate keypair on initialization and provide public_key_bytes property with proper "p:g:h" UTF-8 format
- Add ElGamal alias for backward compatibility with imports
- Improve frontend error handling with detailed base64 decode error messages
- Update .gitignore to specifically ignore backend/lib/ and backend/lib64/ instead of all lib directories, preserving frontend node_modules-style lib/

This fixes the "Invalid public key format" error that was preventing vote submission during testing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Alexis Bruneteau 2025-11-07 18:19:48 +01:00
parent 0ea3aa0a4e
commit 3aa988442f
16 changed files with 974 additions and 6 deletions

View File

@ -0,0 +1,55 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "../utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4",
{
variants: {
variant: {
default: "bg-bg-secondary text-text-primary border-text-tertiary",
destructive:
"border-danger/50 text-danger dark:border-danger [&>svg]:text-danger",
success:
"border-success/50 text-success dark:border-success [&>svg]:text-success",
warning:
"border-warning/50 text-warning dark:border-warning [&>svg]:text-warning",
info:
"border-info/50 text-info dark:border-info [&>svg]:text-info",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,41 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "../utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-accent-warm focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-text-tertiary bg-bg-secondary text-text-primary hover:bg-bg-overlay-light",
secondary:
"border-text-tertiary text-text-secondary hover:bg-bg-overlay-light",
destructive:
"border-danger/50 bg-danger/10 text-danger hover:bg-danger/20",
outline: "text-text-primary border-text-tertiary",
success:
"border-success/50 bg-success/10 text-success hover:bg-success/20",
warning:
"border-warning/50 bg-warning/10 text-warning hover:bg-warning/20",
info:
"border-info/50 bg-info/10 text-info hover:bg-info/20",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,57 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "../utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-bg-secondary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-warm focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-accent-warm text-white hover:bg-opacity-90 active:bg-opacity-80",
destructive:
"bg-danger text-white hover:bg-opacity-90 active:bg-opacity-80",
outline:
"border border-text-tertiary hover:bg-bg-overlay-light hover:text-text-primary",
secondary:
"bg-bg-secondary text-text-primary border border-text-tertiary hover:bg-bg-overlay-light",
ghost:
"hover:bg-bg-overlay-light hover:text-text-primary",
link:
"text-accent-warm underline-offset-4 hover:underline",
success:
"bg-success text-white hover:bg-opacity-90 active:bg-opacity-80",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
if (asChild && props.children && React.isValidElement(props.children)) {
return React.cloneElement(props.children, {
className: cn(buttonVariants({ variant, size }), props.children.props.className),
ref,
})
}
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
})
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,54 @@
import * as React from "react"
import { cn } from "../utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border border-text-tertiary bg-bg-secondary shadow-md card-elevation", className)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6 border-b border-text-tertiary", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight text-text-primary", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-text-secondary", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,100 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "../utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef(({ className, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-text-tertiary bg-bg-secondary p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-bg-secondary transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-accent-warm focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-bg-overlay-light data-[state=open]:text-text-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight text-text-primary",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-text-secondary", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,158 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "../utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-bg-overlay-light data-[state=open]:bg-bg-overlay-light text-text-primary",
inset && "pl-8",
className
)}
{...props}
>
{props.children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"min-w-[8rem] overflow-hidden rounded-md border border-text-tertiary bg-bg-secondary p-1 text-text-primary shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-2 data-[state=open]:slide-in-from-right-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"min-w-[8rem] overflow-hidden rounded-md border border-text-tertiary bg-bg-secondary p-1 text-text-primary shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-2 data-[state=open]:slide-in-from-right-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-bg-overlay-light focus:text-text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50 text-text-primary",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef(({ className, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-bg-overlay-light focus:text-text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50 text-text-primary",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-bg-overlay-light focus:text-text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50 text-text-primary",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-text-primary",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-text-tertiary", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}) => (
<span
className={cn("ml-auto text-xs tracking-widest text-text-secondary", className)}
{...props}
/>
)
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,8 @@
export { Button } from "./button"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from "./card"
export { Alert, AlertTitle, AlertDescription } from "./alert"
export { Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription } from "./dialog"
export { Input } from "./input"
export { Label } from "./label"
export { Badge } from "./badge"
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuGroup, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from "./dropdown-menu"

View File

@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "../utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-text-tertiary bg-bg-secondary px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary ring-offset-bg-secondary transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-warm focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
))
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,16 @@
import * as React from "react"
import { cn } from "../utils"
const Label = React.forwardRef(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-text-primary",
className
)}
{...props}
/>
))
Label.displayName = "Label"
export { Label }

View File

@ -0,0 +1,6 @@
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs))
}

View File

@ -12,8 +12,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
backend/lib/
backend/lib64/
parts/
sdist/
var/

View File

@ -56,6 +56,9 @@ class ElGamalEncryption:
self.p = p
self.g = g
# Generate keypair on initialization
self.public_key, self.private_key = self.generate_keypair()
def generate_keypair(self) -> Tuple[PublicKey, PrivateKey]:
"""Générer une paire de clés ElGamal"""
import random
@ -67,6 +70,18 @@ class ElGamalEncryption:
return public, private
@property
def public_key_bytes(self) -> bytes:
"""
Return public key as serialized bytes in format: p:g:h
This is used for storage in database and transmission to frontend.
The frontend expects this format to be base64-encoded (single layer).
"""
# Format: "23:5:13" as bytes
serialized = f"{self.public_key.p}:{self.public_key.g}:{self.public_key.h}"
return serialized.encode('utf-8')
def encrypt(self, public_key: PublicKey, message: int) -> Ciphertext:
"""
Chiffrer un message avec ElGamal.
@ -160,3 +175,7 @@ class SymmetricEncryption:
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
return plaintext
# Alias for backwards compatibility and ease of use
ElGamal = ElGamalEncryption

View File

@ -0,0 +1,11 @@
/**
* API Configuration
* Determines the backend URL based on environment
*/
export function getBackendUrl(): string {
// In production (Docker), use the backend service name
// In development, use localhost
const isProduction = process.env.NODE_ENV === 'production'
return isProduction ? 'http://backend:8000' : 'http://localhost:8000'
}

View File

@ -0,0 +1,409 @@
/**
* Client-side cryptographic operations for secure voting
*
* Implements:
* - ElGamal encryption for vote secrecy
* - Zero-knowledge proofs for ballot validity
* - Digital signatures for ballot authentication
*/
// Helper functions for bytes conversion (kept for future use)
// /**
// * Convert bytes to base64 string
// */
// function bytesToBase64(bytes: Uint8Array): string {
// let binary = "";
// for (let i = 0; i < bytes.length; i++) {
// binary += String.fromCharCode(bytes[i]);
// }
// return btoa(binary);
// }
// /**
// * Convert base64 string to bytes
// */
// function base64ToBytes(base64: string): Uint8Array {
// const binary = atob(base64);
// const bytes = new Uint8Array(binary.length);
// for (let i = 0; i < binary.length; i++) {
// bytes[i] = binary.charCodeAt(i);
// }
// return bytes;
// }
/**
* Convert number to hex string
*/
function numberToHex(num: number): string {
return num.toString(16).padStart(2, "0");
}
/**
* Convert hex string to number
*/
function hexToNumber(hex: string): number {
return parseInt(hex, 16);
}
/**
* Simple ElGamal encryption implementation for client-side use
* Note: This is a simplified version using JavaScript BigInt
*/
export class ElGamalEncryption {
/**
* Encrypt a vote using ElGamal with public key
*
* Vote encoding:
* - 0 = "No"
* - 1 = "Yes"
*
* @param vote The vote value (0 or 1)
* @param publicKeyBase64 Base64-encoded public key from server
* @returns Encrypted vote as base64 string
*/
static encrypt(vote: number, publicKeyBase64: string): string {
if (vote !== 0 && vote !== 1) {
throw new Error("Vote must be 0 or 1");
}
try {
// Decode the base64 public key
// Format from backend: base64("p:g:h") where p, g, h are decimal numbers
let publicKeyStr: string;
try {
publicKeyStr = atob(publicKeyBase64);
} catch (e) {
throw new Error(`Failed to decode public key from base64: ${e}`);
}
// Parse public key (format: p:g:h separated by colons)
const publicKeyData = publicKeyStr.split(":");
if (publicKeyData.length < 3) {
throw new Error(
`Invalid public key format. Expected "p:g:h" but got "${publicKeyStr}"`
);
}
const p = BigInt(publicKeyData[0]); // Prime
const g = BigInt(publicKeyData[1]); // Generator
const h = BigInt(publicKeyData[2]); // Public key = g^x mod p
// Validate parameters
if (p <= 0n || g <= 0n || h <= 0n) {
throw new Error("Invalid public key parameters: p, g, h must be positive");
}
// Generate random number for encryption
const maxRandom = Number(p) - 2;
if (maxRandom <= 0) {
throw new Error("Public key prime p is too small");
}
const r = BigInt(Math.floor(Math.random() * maxRandom) + 1);
// ElGamal encryption: (c1, c2) = (g^r mod p, m * h^r mod p)
const c1 = this._modPow(g, r, p);
const c2 = (BigInt(vote) * this._modPow(h, r, p)) % p;
// Return as "c1:c2" in base64
const encrypted = `${c1.toString()}:${c2.toString()}`;
return btoa(encrypted);
} catch (error) {
console.error("ElGamal encryption failed:", error);
throw new Error(
`Encryption failed: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Modular exponentiation: (base^exp) mod mod
* Using BigInt for large numbers
*/
private static _modPow(base: bigint, exp: bigint, mod: bigint): bigint {
if (mod === BigInt(1)) {
return BigInt(0);
}
let result = BigInt(1);
base = base % mod;
while (exp > BigInt(0)) {
if (exp % BigInt(2) === BigInt(1)) {
result = (result * base) % mod;
}
exp = exp >> BigInt(1);
base = (base * base) % mod;
}
return result;
}
}
/**
* Zero-Knowledge Proof for ballot validity
* Proves that a vote is 0 or 1 without revealing which
*/
export class ZeroKnowledgeProof {
/**
* Generate a ZKP that encrypted_vote encodes 0 or 1
*
* @param vote The plaintext vote (0 or 1)
* @param encryptedVote The encrypted vote (base64)
* @param timestamp Client timestamp for proof freshness
* @returns ZKP proof as object containing commitments and challenges
*/
static generateProof(
vote: number,
encryptedVote: string,
timestamp: number
): ZKProofData {
if (vote !== 0 && vote !== 1) {
throw new Error("Vote must be 0 or 1");
}
// Generate random values for commitments
const r0 = Math.random();
const r1 = Math.random();
// Commitment: hash of (encrypted_vote || r || vote_bit)
const commitment0 = this._hashProof(
encryptedVote + r0.toString(),
0
);
const commitment1 = this._hashProof(
encryptedVote + r1.toString(),
1
);
// Challenge: hash of (commitments || timestamp)
const challenge = this._hashChallenge(
commitment0 + commitment1 + timestamp.toString()
);
// Response: simple challenge-response
const response0 = (hexToNumber(commitment0) + hexToNumber(challenge)) % 256;
const response1 = (hexToNumber(commitment1) + hexToNumber(challenge)) % 256;
return {
commitment0,
commitment1,
challenge,
response0: response0.toString(),
response1: response1.toString(),
timestamp,
vote_encoding: vote === 0 ? "no" : "yes"
};
}
/**
* Verify a ZKP proof (server-side validation)
*/
static verifyProof(proof: ZKProofData): boolean {
try {
// Reconstruct challenge
const reconstructed = this._hashChallenge(
proof.commitment0 + proof.commitment1 + proof.timestamp.toString()
);
// Verify challenge matches
return proof.challenge === reconstructed;
} catch (error) {
console.error("ZKP verification failed:", error);
return false;
}
}
private static _hashProof(data: string, bit: number): string {
// Simple hash using character codes
let hash = "";
const combined = data + bit.toString();
for (let i = 0; i < combined.length; i++) {
hash += numberToHex(combined.charCodeAt(i) % 256);
}
return hash.substring(0, 16); // Return 8-byte hex string
}
private static _hashChallenge(data: string): string {
let hash = 0;
for (let i = 0; i < data.length; i++) {
const char = data.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16).padStart(16, "0");
}
}
/**
* Digital signature for ballot authentication
* Uses RSA-PSS (simple implementation for MVP)
*/
export class DigitalSignature {
/**
* Sign a ballot with voter's private key
*
* @param ballotData The ballot data to sign (JSON string)
* @param privateKeyBase64 Base64-encoded private key
* @returns Signature as base64 string
*/
static sign(ballotData: string, privateKeyBase64: string): string {
try {
// For MVP: use simple hash-based signing
// In production: would use Web Crypto API with proper RSA-PSS
const signature = this._simpleSign(ballotData, privateKeyBase64);
return btoa(signature);
} catch (error) {
console.error("Signature generation failed:", error);
throw new Error("Signing failed");
}
}
/**
* Verify a ballot signature
*
* @param ballotData Original ballot data (JSON string)
* @param signatureBase64 Signature in base64
* @param publicKeyBase64 Base64-encoded public key
* @returns true if signature is valid
*/
static verify(
ballotData: string,
signatureBase64: string,
publicKeyBase64: string
): boolean {
try {
const signature = atob(signatureBase64);
const reconstructed = this._simpleSign(ballotData, publicKeyBase64);
return signature === reconstructed;
} catch (error) {
console.error("Signature verification failed:", error);
return false;
}
}
private static _simpleSign(data: string, key: string): string {
// Simple hash-based signing for MVP
// Combines data with key using basic hashing
let hash = 0;
const combined = data + key;
for (let i = 0; i < combined.length; i++) {
const char = combined.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(32, "0");
}
}
/**
* Ballot structure for voting
*/
export interface Ballot {
voter_id: string;
encrypted_vote: string; // Base64
zkp_proof: ZKProofData;
signature: string; // Base64
timestamp: number;
}
/**
* ZKP proof data
*/
export interface ZKProofData {
commitment0: string;
commitment1: string;
challenge: string;
response0: string;
response1: string;
timestamp: number;
vote_encoding: string;
}
/**
* Public keys response from server
*/
export interface PublicKeysResponse {
paillier_pubkey?: string; // Base64
elgamal_pubkey?: string; // Base64 format: p:g:h
authority_pubkey?: string; // Base64
}
/**
* Encrypt and sign a ballot for submission
*
* @param vote Vote value (0 or 1)
* @param voterId Voter ID
* @param publicKeysBase64 Public encryption key
* @param voterPrivateKeyBase64 Voter's private key for signing
* @returns Complete signed ballot ready for submission
*/
export function createSignedBallot(
vote: number,
voterId: string,
publicKeysBase64: string,
voterPrivateKeyBase64: string
): Ballot {
const timestamp = Date.now();
// 1. Encrypt the vote
const encryptedVote = ElGamalEncryption.encrypt(vote, publicKeysBase64);
// 2. Generate ZK proof
const zkpProof = ZeroKnowledgeProof.generateProof(vote, encryptedVote, timestamp);
// 3. Create ballot structure
const ballotData = JSON.stringify({
voter_id: voterId,
encrypted_vote: encryptedVote,
zkp_proof: zkpProof,
timestamp
});
// 4. Sign the ballot
const signature = DigitalSignature.sign(ballotData, voterPrivateKeyBase64);
return {
voter_id: voterId,
encrypted_vote: encryptedVote,
zkp_proof: zkpProof,
signature,
timestamp
};
}
/**
* Verify ballot integrity and signature
*/
export function verifyBallot(
ballot: Ballot,
publicKeyBase64: string
): boolean {
try {
// Reconstruct ballot data for verification
const ballotData = JSON.stringify({
voter_id: ballot.voter_id,
encrypted_vote: ballot.encrypted_vote,
zkp_proof: ballot.zkp_proof,
timestamp: ballot.timestamp
});
// Verify signature
const signatureValid = DigitalSignature.verify(
ballotData,
ballot.signature,
publicKeyBase64
);
// Verify ZKP
const zkpValid = ZeroKnowledgeProof.verifyProof(ballot.zkp_proof);
return signatureValid && zkpValid;
} catch (error) {
console.error("Ballot verification failed:", error);
return false;
}
}

View File

@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}