[794-7][@ant] feat: verifier-spa загрузка PDF/.sig/ZIP + оркестратор C1–C8 и вердикт — локальная проверка целостности и подписи плюс C6 через api, контент не уходит на сервер (приватность)

This commit is contained in:
coopops
2026-06-03 07:31:25 +00:00
parent 0224167494
commit 4146d1f0dc
7 changed files with 335 additions and 0 deletions
+35
View File
@@ -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>
+59
View File
@@ -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;
}
}
+61
View File
@@ -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 };
}
+98
View File
@@ -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 };
/**
* Оркестратор C1C8 (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;
}
+6
View File
@@ -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');
+14
View File
@@ -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;
}