- 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.
482 lines
16 KiB
Svelte
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>
|