Files
software-station/frontend/src/lib/components/VerificationModal.svelte
Sudo-Ivan 55eaf28514
All checks were successful
CI / build (push) Successful in 1m0s
renovate / renovate (push) Successful in 1m14s
Update error handling in verifier and improve error message display in VerificationModal
- Updated the error handling in loadVerifier to log detailed errors and provide clearer feedback on WASM script loading issues.
- Modified the error message display in VerificationModal to better format and separate error details for improved user experience.
2025-12-27 22:45:57 -06:00

482 lines
16 KiB
Svelte

<script lang="ts">
import {
Shield,
ShieldAlert,
ShieldCheck,
Loader2,
X,
Download,
ExternalLink,
Lock,
Users,
CheckCircle2,
Key,
Copy,
Check,
} from 'lucide-svelte';
import { verifyAsset } from '$lib/verifier';
import { backgroundVerify } from '$lib/verificationStore';
import type { Contributor, SourceSecurity, VerificationStep } from '$lib/types';
interface Props {
assetName: string;
assetUrl: string;
expectedHash: string;
contributors?: Contributor[];
security?: SourceSecurity;
onClose: () => void;
}
let { assetName, assetUrl, expectedHash, contributors, security, onClose }: Props = $props();
let dontShowAgain = $state(false);
let selectedContributor = $state<Contributor | null>(null);
let copiedKeyIndex = $state<number | null>(null);
let status = $state<'idle' | 'downloading' | 'verifying' | 'success' | 'error'>('idle');
let errorMessage = $state('');
let progress = $state(0);
let steps = $state<VerificationStep[]>([]);
function copyKey(key: string, index: number) {
navigator.clipboard.writeText(key);
copiedKeyIndex = index;
setTimeout(() => (copiedKeyIndex = null), 2000);
}
function savePreference(accepted: boolean) {
if (dontShowAgain) {
localStorage.setItem('verification_preference', accepted ? 'accept' : 'decline');
}
}
async function startVerification() {
savePreference(true);
// If "Don't show again" is checked and accepted, trigger background verification and close modal
if (dontShowAgain) {
backgroundVerify(assetName, assetUrl, expectedHash);
onClose();
return;
}
status = 'downloading';
try {
const response = await fetch(assetUrl);
if (!response.ok) throw new Error('Download failed');
const contentLength = response.headers.get('content-length');
const total = contentLength ? parseInt(contentLength, 10) : 0;
const contentType = response.headers.get('content-type') || 'application/octet-stream';
const reader = response.body?.getReader();
if (!reader) throw new Error('Failed to read response body');
let receivedLength = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
receivedLength += value.length;
if (total) progress = Math.round((receivedLength / total) * 100);
}
const data = new Uint8Array(receivedLength);
let position = 0;
for (const chunk of chunks) {
data.set(chunk, position);
position += chunk.length;
}
status = 'verifying';
const result = await verifyAsset(data.buffer, expectedHash);
steps = result.steps;
if (result.valid) {
status = 'success';
// Trigger actual download from the blob
const blob = new Blob([data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = assetName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else {
status = 'error';
errorMessage = result.error || 'Verification failed';
}
} catch (e: any) {
status = 'error';
errorMessage = e.message;
}
}
function skipVerification() {
savePreference(false);
const a = document.createElement('a');
a.href = assetUrl;
a.download = assetName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
onClose();
}
</script>
<div
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm"
>
<div
class="w-full max-w-md bg-card border border-border rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
>
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-primary/10 text-primary">
<Shield class="w-6 h-6" />
</div>
<div>
<h3 class="text-xl font-bold leading-none">Verify Download</h3>
<a
href="https://git.quad4.io/Quad4-Software/software-station/src/branch/master/software-verifier"
target="_blank"
class="text-[10px] text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 mt-1"
>
Code <ExternalLink class="w-2.5 h-2.5" />
</a>
</div>
</div>
<button
onclick={onClose}
class="p-2 hover:bg-muted rounded-full transition-colors"
aria-label="Close"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="space-y-4">
<div class="bg-muted/30 rounded-xl p-4 border border-border/50">
<p class="text-xs text-muted-foreground mb-2">Target Asset</p>
<p class="text-sm font-bold truncate">{assetName}</p>
{#if security}
<div class="flex items-center gap-4 mt-3 pt-3 border-t border-border/50">
<div class="flex items-center gap-1.5" title="TLS Certificate Validation">
<Lock
class="w-3.5 h-3.5 {security.tls_valid
? 'text-green-700 dark:text-green-400'
: 'text-destructive'}"
/>
<span class="text-[10px] font-bold uppercase tracking-tight">TLS</span>
</div>
<div class="flex-1 text-right">
<span class="text-[10px] text-muted-foreground font-medium">{security.domain}</span>
</div>
</div>
{/if}
</div>
{#if contributors && contributors.length > 0}
<div class="bg-muted/30 rounded-xl p-4 border border-border/50">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<Users class="w-4 h-4 text-primary" />
<span class="text-xs font-bold uppercase tracking-wider">Contributors</span>
</div>
<span class="text-[10px] text-muted-foreground font-medium"
>{contributors.length} Users</span
>
</div>
<div class="flex flex-wrap gap-2">
{#each contributors as contributor}
<button
onclick={() =>
(selectedContributor =
selectedContributor?.username === contributor.username ? null : contributor)}
class="flex items-center gap-1.5 bg-background border {selectedContributor?.username ===
contributor.username
? 'border-primary shadow-sm'
: 'border-border'} rounded-full pl-1 pr-2 py-1 transition-all hover:border-primary/50"
title={contributor.username}
>
<img
src={contributor.avatar_url}
alt={contributor.username}
class="w-4 h-4 rounded-full"
/>
<span class="text-[10px] font-medium max-w-[80px] truncate"
>{contributor.username}</span
>
{#if contributor.gpg_keys && contributor.gpg_keys.length > 0}
<Key class="w-2.5 h-2.5 text-orange-500" />
{/if}
</button>
{/each}
</div>
{#if selectedContributor}
<div
class="mt-4 pt-4 border-t border-border/50 animate-in fade-in slide-in-from-top-1 duration-200"
>
<div class="flex items-center justify-between mb-2">
<p class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
Public GPG Keys
</p>
<span
class="text-[9px] px-1.5 py-0.5 rounded bg-orange-500/10 text-orange-700 dark:text-orange-400 font-bold"
>{selectedContributor.gpg_keys?.length || 0} Keys</span
>
</div>
{#if selectedContributor.gpg_keys && selectedContributor.gpg_keys.length > 0}
<div class="space-y-2">
{#each selectedContributor.gpg_keys as key, idx}
<div class="group relative">
<pre
class="text-[8px] bg-zinc-950 text-zinc-400 p-2 rounded-lg overflow-x-auto font-mono leading-tight max-h-32 scrollbar-thin"><code
>{key}</code
></pre>
<button
onclick={() => copyKey(key, idx)}
class="absolute top-2 right-2 p-1.5 bg-white/5 hover:bg-white/10 rounded-md transition-colors text-white/50 hover:text-white"
title="Copy Public Key"
>
{#if copiedKeyIndex === idx}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3" />
{/if}
</button>
</div>
{/each}
</div>
{:else}
<p class="text-[10px] text-muted-foreground italic">
No public GPG keys found for this user.
</p>
{/if}
</div>
{/if}
</div>
{/if}
{#if status === 'idle'}
<div class="flex flex-col gap-3 pt-2">
<button
onclick={startVerification}
class="w-full py-3 px-4 bg-primary text-primary-foreground rounded-xl font-semibold hover:opacity-90 transition-opacity flex items-center justify-center gap-2 shadow-lg shadow-primary/20"
>
<ShieldCheck class="w-5 h-5" />
Verify and Download
</button>
<button
onclick={skipVerification}
class="w-full py-3 px-4 bg-muted text-muted-foreground rounded-xl font-semibold hover:bg-muted/80 transition-colors flex items-center justify-center gap-2"
>
<Download class="w-5 h-5" />
Download without verification
</button>
</div>
<div class="flex items-center gap-2 mt-2">
<label class="flex items-center gap-2 cursor-pointer group">
<div class="relative flex items-center">
<input type="checkbox" bind:checked={dontShowAgain} class="peer sr-only" />
<div
class="w-4 h-4 rounded border border-input bg-background peer-checked:bg-primary peer-checked:border-primary transition-all"
></div>
<svg
class="absolute w-3 h-3 text-primary-foreground opacity-0 peer-checked:opacity-100 transition-opacity left-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="4"
>
<path d="M5 13l4 4L19 7" />
</svg>
</div>
<span
class="text-[11px] text-muted-foreground group-hover:text-foreground transition-colors"
>Remember this preference</span
>
</label>
</div>
{:else if status === 'downloading' || status === 'verifying'}
<div
class="py-8 bg-muted/20 rounded-xl border border-border/50 flex flex-col items-center justify-center gap-4"
>
<div class="relative w-16 h-16">
<Loader2 class="w-16 h-16 text-primary animate-spin opacity-20" />
<div class="absolute inset-0 flex items-center justify-center">
{#if status === 'downloading'}
<div class="text-sm font-bold text-primary">{progress}%</div>
{:else}
<Shield class="w-8 h-8 text-primary animate-pulse" />
{/if}
</div>
</div>
<div class="text-center">
<p class="text-sm font-bold tracking-tight">
{#if status === 'downloading'}
Downloading asset...
{:else}
Running WASM verification...
{/if}
</p>
<p class="text-[10px] text-muted-foreground mt-1">
{status === 'downloading'
? `Fetching raw binary from source${progress > 0 ? ` • ${progress}%` : ''}`
: 'Computing SHA256 in sandbox'}
</p>
</div>
</div>
{:else if status === 'success'}
<div class="space-y-4">
<div
class="py-8 bg-green-500/5 rounded-xl border border-green-500/20 flex flex-col items-center justify-center gap-4 text-green-700 dark:text-green-400"
>
<div class="p-4 rounded-full bg-green-500/10">
<ShieldCheck class="w-12 h-12" />
</div>
<div class="text-center">
<p class="font-bold text-lg leading-tight">Verification Successful!</p>
<p class="text-xs text-muted-foreground mt-1">Binary integrity is guaranteed</p>
</div>
</div>
{#if steps.length > 0}
<div class="bg-muted/30 rounded-xl overflow-hidden border border-border/50">
<div
class="px-4 py-2 bg-muted/50 border-b border-border/50 flex items-center gap-2"
>
<CheckCircle2 class="w-3 h-3 text-green-700 dark:text-green-400" />
<span class="text-[10px] font-bold uppercase tracking-wider"
>Verification Audit</span
>
</div>
<div class="p-3 space-y-3">
{#each steps as step}
<div class="flex gap-3">
<div class="mt-0.5">
{#if step.status === 'success'}
<CheckCircle2 class="w-3 h-3 text-green-700 dark:text-green-400" />
{:else if step.status === 'error'}
<ShieldAlert class="w-3 h-3 text-destructive" />
{:else}
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin" />
{/if}
</div>
<div class="min-w-0">
<p class="text-[11px] font-bold leading-none">{step.name}</p>
<p class="text-[10px] text-muted-foreground mt-1 leading-normal">
{step.details}
</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
<button
onclick={onClose}
class="w-full py-3 px-4 bg-green-700 dark:bg-green-600 text-white rounded-xl font-semibold hover:opacity-90 transition-opacity flex items-center justify-center gap-2"
>
Start Using Software
</button>
</div>
{:else if status === 'error'}
<div class="space-y-4">
<div
class="py-8 bg-destructive/5 rounded-xl border border-destructive/20 flex flex-col items-center justify-center gap-4 text-destructive"
>
<div class="p-4 rounded-full bg-destructive/10">
<ShieldAlert class="w-12 h-12" />
</div>
<div class="text-center px-4">
<p class="font-bold text-lg leading-tight">Verification Failed</p>
{#if errorMessage.includes('(')}
<p class="text-sm font-bold mt-1">
{errorMessage.split(' (')[0]}
</p>
<p class="text-[10px] text-muted-foreground mt-0.5 italic">
({errorMessage.split(' (')[1]}
</p>
{:else}
<p class="text-xs text-muted-foreground mt-1">{errorMessage}</p>
{/if}
</div>
</div>
{#if steps.length > 0}
<div class="bg-muted/30 rounded-xl overflow-hidden border border-border/50">
<div
class="px-4 py-2 bg-muted/50 border-b border-border/50 flex items-center gap-2"
>
<ShieldAlert class="w-3 h-3 text-destructive" />
<span class="text-[10px] font-bold uppercase tracking-wider text-destructive"
>Failure Analysis</span
>
</div>
<div class="p-3 space-y-3">
{#each steps as step}
<div class="flex gap-3">
<div class="mt-0.5">
{#if step.status === 'success'}
<CheckCircle2 class="w-3 h-3 text-green-700 dark:text-green-400" />
{:else if step.status === 'error'}
<ShieldAlert class="w-3 h-3 text-destructive" />
{:else}
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin" />
{/if}
</div>
<div class="min-w-0">
<p class="text-[11px] font-bold leading-none">{step.name}</p>
<p class="text-[10px] text-muted-foreground mt-1 leading-normal">
{step.details}
</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
<div class="flex gap-3 pt-2">
<button
onclick={() => (status = 'idle')}
class="flex-1 py-3 px-4 bg-muted text-muted-foreground rounded-xl font-semibold hover:bg-muted/80 transition-colors"
>
Try Again
</button>
<button
onclick={skipVerification}
class="flex-1 py-3 px-4 bg-destructive text-destructive-foreground rounded-xl font-semibold hover:opacity-90 transition-opacity"
>
Download Anyway
</button>
</div>
</div>
{/if}
</div>
</div>
<div class="px-6 py-4 bg-muted/50 border-t border-border flex items-center justify-between">
<div
class="flex items-center gap-2 text-[10px] text-muted-foreground uppercase tracking-wider font-bold"
>
<span class="px-1.5 py-0.5 rounded bg-primary/10 text-primary border border-primary/20"
>WASM</span
>
<span>Client-side security</span>
</div>
<p class="text-[10px] text-muted-foreground">Powered by Go</p>
</div>
</div>
</div>