[794-7][@ant] feat: verifier-spa загрузка PDF/.sig/ZIP + оркестратор C1–C8 и вердикт — локальная проверка целостности и подписи плюс C6 через api, контент не уходит на сервер (приватность)
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { fetchHealth, type HealthResponse } from '../lib/api.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const health = ref<HealthResponse | null>(null);
|
||||
let timer: number | undefined;
|
||||
|
||||
async function poll() {
|
||||
health.value = await fetchHealth();
|
||||
}
|
||||
onMounted(() => {
|
||||
poll();
|
||||
timer = window.setInterval(poll, 15000);
|
||||
});
|
||||
onUnmounted(() => window.clearInterval(timer));
|
||||
|
||||
const status = computed(() => health.value?.status ?? 'offline');
|
||||
const color = computed(() =>
|
||||
status.value === 'synced'
|
||||
? 'var(--color-verdict-valid)'
|
||||
: status.value === 'lagging'
|
||||
? 'var(--color-verdict-warn)'
|
||||
: 'var(--color-verdict-invalid)',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2 text-xs" style="color: var(--color-mauve-11)">
|
||||
<span class="inline-block h-2 w-2 rounded-full" :style="{ background: color }" />
|
||||
<span>{{ t(`health.${status}`) }}</span>
|
||||
<span v-if="health"> · head {{ health.head_block }} / current {{ health.current_block }} </span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { parseSingle, parseZip, type ParseResult } from '../lib/parse.js';
|
||||
|
||||
const emit = defineEmits<{ parsed: [ParseResult]; error: [string] }>();
|
||||
const { t } = useI18n();
|
||||
const dragging = ref(false);
|
||||
|
||||
async function handleFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
const list = Array.from(files);
|
||||
try {
|
||||
const zip = list.find((f) => f.name.endsWith('.zip'));
|
||||
if (zip) {
|
||||
emit('parsed', await parseZip(zip));
|
||||
return;
|
||||
}
|
||||
const pdf = list.find((f) => f.name.endsWith('.pdf'));
|
||||
const sig = list.find((f) => f.name.endsWith('.sig'));
|
||||
if (pdf && sig) {
|
||||
emit('parsed', await parseSingle(pdf, sig));
|
||||
return;
|
||||
}
|
||||
emit('error', 'Нужны PDF + .sig или ZIP-архив');
|
||||
} catch (e) {
|
||||
emit('error', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
dragging.value = false;
|
||||
handleFiles(e.dataTransfer?.files ?? null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="plane flex flex-col items-center justify-center gap-3 p-12 text-center transition-colors"
|
||||
:style="dragging ? { borderColor: 'var(--color-brand-9)', background: 'var(--color-brand-3)' } : {}"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<p class="text-sm" style="color: var(--color-mauve-12)">{{ t('upload.drop') }}</p>
|
||||
<p class="text-xs" style="color: var(--color-mauve-11)">{{ t('upload.or') }}</p>
|
||||
<label
|
||||
class="cursor-pointer rounded-lg px-4 py-2 text-sm font-medium text-white"
|
||||
style="background: var(--color-brand-9)"
|
||||
>
|
||||
{{ t('upload.choose') }}
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
multiple
|
||||
accept=".pdf,.sig,.zip"
|
||||
@change="handleFiles(($event.target as HTMLInputElement).files)"
|
||||
/>
|
||||
</label>
|
||||
<p class="text-xs" style="color: var(--color-mauve-11)">{{ t('upload.hint') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:3030';
|
||||
|
||||
export interface ActiveCheckResponse {
|
||||
active: boolean;
|
||||
username: string;
|
||||
public_key: string;
|
||||
at: string;
|
||||
scope: 'active-permission';
|
||||
last_known_block: number | null;
|
||||
last_known_block_time: string | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
head_block: number;
|
||||
current_block: number;
|
||||
behind_by: number;
|
||||
status: 'synced' | 'lagging' | 'offline';
|
||||
bootstrap_done: boolean;
|
||||
}
|
||||
|
||||
export type C6Outcome =
|
||||
| { ok: true; data: ActiveCheckResponse }
|
||||
| { ok: false; reason: 'not_indexed' | 'beyond_index' | 'unavailable'; message: string };
|
||||
|
||||
/**
|
||||
* C6: проверка ключа на момент. Приватность — передаём ТОЛЬКО username, public_key, signed_at.
|
||||
* Контент документа на сервер не уходит.
|
||||
*/
|
||||
export async function checkActivePermission(
|
||||
username: string,
|
||||
publicKey: string,
|
||||
at: string,
|
||||
): Promise<C6Outcome> {
|
||||
const url = new URL('/v1/active-permission/check', API_BASE);
|
||||
url.searchParams.set('username', username);
|
||||
url.searchParams.set('public_key', publicKey);
|
||||
url.searchParams.set('at', at);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.status === 404) return { ok: false, reason: 'not_indexed', message: 'Аккаунт не в индексе' };
|
||||
if (res.status === 503)
|
||||
return { ok: false, reason: 'beyond_index', message: 'Индексатор не дошёл до этого момента' };
|
||||
if (!res.ok) return { ok: false, reason: 'unavailable', message: `HTTP ${res.status}` };
|
||||
return { ok: true, data: (await res.json()) as ActiveCheckResponse };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: 'unavailable', message: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<HealthResponse | null> {
|
||||
try {
|
||||
const res = await fetch(new URL('/v1/health', API_BASE));
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as HealthResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { unzipSync } from 'fflate';
|
||||
import type { SigFile, PackageManifest } from '@verifier/shared';
|
||||
|
||||
export interface ParsedDocument {
|
||||
name: string;
|
||||
pdf: Uint8Array;
|
||||
sig: SigFile;
|
||||
/** Архив старого формата (signature.txt вместо .sig) — показываем бейдж legacy. */
|
||||
legacy: boolean;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
documents: ParsedDocument[];
|
||||
/** true — был распарсен ZIP-пакет (US-2). */
|
||||
isPackage: boolean;
|
||||
}
|
||||
|
||||
/** Одиночный PDF + .sig. */
|
||||
export async function parseSingle(pdf: File, sig: File): Promise<ParseResult> {
|
||||
const sigJson = JSON.parse(await sig.text()) as SigFile;
|
||||
const pdfBytes = new Uint8Array(await pdf.arrayBuffer());
|
||||
return {
|
||||
documents: [{ name: pdf.name.replace(/\.pdf$/i, ''), pdf: pdfBytes, sig: sigJson, legacy: false }],
|
||||
isPackage: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** ZIP-пакет: распознаётся по manifest.json; парсит пары <doc>.pdf/<doc>.sig. */
|
||||
export async function parseZip(zip: File): Promise<ParseResult> {
|
||||
const files = unzipSync(new Uint8Array(await zip.arrayBuffer()));
|
||||
const dec = new TextDecoder();
|
||||
const manifestRaw = files['manifest.json'];
|
||||
|
||||
if (manifestRaw) {
|
||||
const manifest = JSON.parse(dec.decode(manifestRaw)) as PackageManifest;
|
||||
const documents: ParsedDocument[] = manifest.documents.map((entry) => {
|
||||
const pdf = files[entry.document];
|
||||
const sigRaw = files[entry.signature];
|
||||
if (!pdf || !sigRaw) throw new Error(`В пакете нет файлов для ${entry.name}`);
|
||||
return { name: entry.name, pdf, sig: JSON.parse(dec.decode(sigRaw)) as SigFile, legacy: false };
|
||||
});
|
||||
return { documents, isPackage: true };
|
||||
}
|
||||
|
||||
// legacy: нет manifest.json — ищем пары *.pdf + *.sig напрямую
|
||||
const documents: ParsedDocument[] = [];
|
||||
for (const path of Object.keys(files)) {
|
||||
if (!path.endsWith('.pdf')) continue;
|
||||
const base = path.replace(/\.pdf$/i, '');
|
||||
const sigRaw = files[`${base}.sig`];
|
||||
if (!sigRaw) continue;
|
||||
documents.push({
|
||||
name: base,
|
||||
pdf: files[path]!,
|
||||
sig: JSON.parse(dec.decode(sigRaw)) as SigFile,
|
||||
legacy: !!files[`${base}.signature.txt`],
|
||||
});
|
||||
}
|
||||
if (documents.length === 0) throw new Error('В архиве не найдено пар PDF + .sig');
|
||||
return { documents, isPackage: documents.length > 1 };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
checkIntegrity,
|
||||
checkMetaHash,
|
||||
checkCompositeHash,
|
||||
checkSignature,
|
||||
checkCertificatePresence,
|
||||
webCryptoSha256,
|
||||
type CheckResult,
|
||||
} from '@verifier/shared';
|
||||
import type { ParsedDocument } from './parse.js';
|
||||
import { checkActivePermission } from './api.js';
|
||||
|
||||
export type Verdict = 'valid' | 'invalid' | 'warn';
|
||||
|
||||
export interface ExtendedCheck {
|
||||
check: string;
|
||||
status: 'pass' | 'fail' | 'warn';
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface DocumentVerification {
|
||||
name: string;
|
||||
legacy: boolean;
|
||||
checks: ExtendedCheck[];
|
||||
verdict: Verdict;
|
||||
}
|
||||
|
||||
const deps = { sha256: webCryptoSha256 };
|
||||
|
||||
/**
|
||||
* Оркестратор C1–C8 (US-3, FR-4):
|
||||
* C1–C5 локально через @verifier/shared (приватность — контент не уходит).
|
||||
* C6 через api на каждую подпись (передаём только username/public_key/signed_at).
|
||||
* C7 всегда ⚠ самоподписан (не блокирует). C8 — задел (chain.json).
|
||||
*
|
||||
* Вердикт: ❌ если любая C1–C6 fail; ✅ если все зелёные; ⚠ если C6 недоступна (деградация).
|
||||
*/
|
||||
export async function verifyDocument(doc: ParsedDocument): Promise<DocumentVerification> {
|
||||
const checks: ExtendedCheck[] = [];
|
||||
|
||||
const c1 = await checkIntegrity(doc.pdf, doc.sig, deps);
|
||||
const c2 = await checkMetaHash(doc.sig, deps);
|
||||
const c3 = await checkCompositeHash(doc.sig, deps);
|
||||
const c4 = await checkSignature(doc.sig, deps);
|
||||
const c5 = checkCertificatePresence(doc.sig);
|
||||
checks.push(...[c1, c2, c3, c4, c5].map(toExt));
|
||||
|
||||
// C6 — по каждой подписи
|
||||
let c6Degraded = false;
|
||||
let c6Failed = false;
|
||||
for (const s of doc.sig.signatures) {
|
||||
const out = await checkActivePermission(s.signer_certificate.username, s.public_key, s.signed_at);
|
||||
if (out.ok) {
|
||||
const ok = out.data.active;
|
||||
if (!ok) c6Failed = true;
|
||||
checks.push({
|
||||
check: 'C6',
|
||||
status: ok ? 'pass' : 'fail',
|
||||
detail: ok
|
||||
? `Ключ ${short(s.public_key)} был активен у ${s.signer_certificate.username} на момент подписи`
|
||||
: `Ключ ${short(s.public_key)} НЕ активен у ${s.signer_certificate.username} на момент подписи`,
|
||||
});
|
||||
} else {
|
||||
c6Degraded = true;
|
||||
checks.push({
|
||||
check: 'C6',
|
||||
status: 'warn',
|
||||
detail: `Не удалось проверить привязку ключа: ${out.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// C7 — самоподписанность (issuer_signature == null)
|
||||
const selfSigned = doc.sig.signatures.every((s) => s.issuer_signature === null);
|
||||
checks.push({
|
||||
check: 'C7',
|
||||
status: 'warn',
|
||||
detail: selfSigned
|
||||
? 'Сертификат самоподписан (v1) — известное ограничение, не дефект'
|
||||
: 'Сертификат подписан эмитентом',
|
||||
});
|
||||
|
||||
const localFail = [c1, c2, c3, c4, c5].some((c) => c.status === 'fail');
|
||||
let verdict: Verdict;
|
||||
if (localFail || c6Failed) verdict = 'invalid';
|
||||
else if (c6Degraded) verdict = 'warn';
|
||||
else verdict = 'valid';
|
||||
|
||||
return { name: doc.name, legacy: doc.legacy, checks, verdict };
|
||||
}
|
||||
|
||||
function toExt(c: CheckResult): ExtendedCheck {
|
||||
return { check: c.check, status: c.status, detail: c.detail };
|
||||
}
|
||||
|
||||
function short(key: string): string {
|
||||
return key.length > 14 ? `${key.slice(0, 10)}…${key.slice(-4)}` : key;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { i18n } from './lib/i18n.js';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).use(i18n).mount('#app');
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string;
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user