Merge pull request 'feat: экспорт документов в .sig v2.0 + manifest.json (верификатор)' (#65) from feat/sig-v2-export into dev

Reviewed-on: #65
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
This commit was merged in pull request #65.
This commit is contained in:
2026-06-04 05:48:50 +00:00
5 changed files with 215 additions and 80 deletions
@@ -238,3 +238,84 @@ export interface IGetComplexDocuments2 {
export interface IComplexAgenda2 extends IAgenda {
documents: IComplexDocument2
}
/**
* Режим канонизации `meta` при вычислении meta_hash (см. SigFile v2.0).
* 'legacy-node-stringify' — версия подписи 1.0.0: SHA-256(JSON.stringify(meta)), недетерминированная (зависит от порядка ключей).
* 'jcs-1.0' — версия подписи 1.1.0: RFC 8785, детерминированная (порядок ключей не влияет).
*/
export type CanonicalizationMode = 'jcs-1.0' | 'legacy-node-stringify'
/**
* Режим канонизации по версии алгоритма подписи документа.
* 1.1.0+ → детерминированный JCS; всё остальное (1.0.0/пусто) → legacy.
* Единый источник соответствия для desktop-экспорта .sig и верификатора.
*/
export function canonicalizationForVersion(version: string | undefined): CanonicalizationMode {
return version === '1.1.0' ? 'jcs-1.0' : 'legacy-node-stringify'
}
/** OID secp256k1 ECDSA. */
export const SIG_ALGORITHM_OID_SECP256K1 = '1.3.132.0.10'
/** OID SHA-256. */
export const SIG_HASH_OID_SHA256 = '2.16.840.1.101.3.4.2.1'
/**
* Одна подпись в detached-формате `.sig` v2.0. Самодостаточна: содержит встроенный
* сертификат подписанта. issuer_signature в v1 = null (самоподписан).
*/
export interface SigFileSignature {
/** Публичный ключ подписанта (EOS...). */
public_key: string
/** Подпись над signed_hash (SIG_K1_...). */
signature: string
/** ISO-8601 момент подписания (формат SDK: без миллисекунд/Z). Входит в signed_hash. */
signed_at: string
/** SHA-256(hash || signed_at) — то, что реально подписано. */
signed_hash: string
/** Встроенный сертификат подписанта — UserCertificateUnion из GraphQL (Individual/Entrepreneur/Organization). */
signer_certificate: unknown
/** Подпись эмитента сертификата; v1 = null (самоподписан). */
issuer_signature: string | null
}
/**
* Канонический формат detached-подписи `.sig` v2.0 — самодостаточный доказательный артефакт
* для верификатора документов (целостность + криптоподпись + историческая привязка ключа).
*
* Хэш-цепочка (как считает SDK Document):
* doc_hash = SHA-256(pdf)
* meta_hash = SHA-256(canonicalize(meta, canonicalization))
* hash = SHA-256(meta_hash || doc_hash)
* signed_hash = SHA-256(hash || signed_at) // подписывается им
*/
export interface SigFile<T = any> {
v: '2.0'
canonicalization: CanonicalizationMode
algorithm: { name: string, oid: string }
hash: { name: string, oid: string }
/** Описание подписанного контента. */
content: { filename: string, mime: string }
canonical: { doc_hash: string, meta_hash: string, hash: string }
/** Метаданные документа; канонизируются для meta_hash. */
meta: IMetaDocument & T
signatures: SigFileSignature[]
}
/**
* Манифест ZIP-пакета документов (массовая проверка). Перечисляет тройки
* <doc>.pdf / <doc>.sig / опц. <doc>.chain.json для верификатора.
*/
export interface PackageManifestEntry {
name: string
document: string
signature: string
chain?: string
}
export interface PackageManifest {
v: '1.0'
generator: string
created_at: string
documents: PackageManifestEntry[]
}
@@ -1,6 +1,15 @@
import type { IDocumentAggregate, IDocumentPackageAggregate } from 'src/entities/Document/model/types';
import { Cooperative } from 'cooptypes';
import { getShortNameFromCertificate } from '../utils/getNameFromCertificate';
/**
* Восстанавливает ровно подписанный формат signed_at (SDK подписывает ISO UTC без дробных секунд и без Z).
* Чистая строковая операция без reparse через Date — нет риска сдвига часового пояса. Гарантирует, что
* .coopsig несёт байт-в-байт ту строку, что вошла в signed_hash, и C4-проверка проходит без нормализации.
*/
const canonicalSignedAt = (s: string | null | undefined): string =>
(s ?? '').replace(/\.\d+/, '').replace(/[zZ]$/, '');
type ZipEntry = {
name: string;
data: Uint8Array;
@@ -355,6 +364,58 @@ const buildArchiveName = (
return sanitizeName(`${base}${hashSuffix}${signerSuffix}`);
};
// OID криптопримитивов для самодостаточного .coopsig (см. cooptypes SigFile v2.0).
const SIG_ALGORITHM_OID_SECP256K1 = '1.3.132.0.10';
const SIG_HASH_OID_SHA256 = '2.16.840.1.101.3.4.2.1';
/**
* Формирует detached-подпись (файл `.coopsig`, формат SigFile v2.0) из агрегата документа.
*
* canonical-значения берём КАК ЕСТЬ из `aggregate.document`: `doc_hash` приходит из фабрики
* в верхнем регистре (SHA-256 байтов PDF), `meta_hash`/`hash` — из SDK в нижнем. Верификатор
* сверяет хэш-цепочку именно по этим строкам (C3 конкатенирует их без нормализации регистра).
*
* Режим канонизации выбирается по версии подписи документа:
* 1.0.0 → 'legacy-node-stringify' (SHA-256(JSON.stringify(meta)), недетерминированный),
* 1.1.0 → 'jcs-1.0' (RFC 8785, детерминированный) — meta_hash совпадает с верификатором.
*/
const buildSigFile = (
aggregate: IDocumentAggregate,
pdfName: string,
signedMeta: Record<string, unknown> | null,
): Record<string, unknown> => {
const doc = aggregate.document;
const signatures = doc?.signatures ?? [];
return {
v: '2.0',
canonicalization: Cooperative.Document.canonicalizationForVersion(doc?.version),
algorithm: { name: 'ecdsa-secp256k1', oid: SIG_ALGORITHM_OID_SECP256K1 },
hash: { name: 'sha256', oid: SIG_HASH_OID_SHA256 },
content: { filename: pdfName, mime: 'application/pdf' },
canonical: {
doc_hash: doc?.doc_hash ?? '',
meta_hash: doc?.meta_hash ?? '',
hash: doc?.hash ?? '',
},
meta: signedMeta ?? {},
signatures: signatures.map((signature) => ({
public_key: signature.public_key,
signature: signature.signature,
signed_at: canonicalSignedAt(signature.signed_at),
signed_hash: signature.signed_hash,
signer_certificate: signature.signer_certificate ?? null,
issuer_signature: null,
})),
};
};
/**
* Имя файла подписи рядом с PDF: <doc>.coopsig (тот же базовый стем, что у PDF).
* Расширение НЕ `.sig` намеренно — чтобы ОС не предлагала открыть его в КриптоПро (наш формат —
* secp256k1 JSON, а не ГОСТ-CMS; совместимости с КриптоПро нет в принципе). Внутри — JSON SigFile v2.0.
*/
const sigFileNameFor = (pdfName: string) => `${pdfName.replace(/\.pdf$/i, '')}.coopsig`;
export const prepareDocumentArchive = async (
aggregate: IDocumentAggregate,
): Promise<{ blob: Blob; archiveName: string; pdfName: string }> => {
@@ -365,10 +426,6 @@ export const prepareDocumentArchive = async (
const pdfBytes = decodeBase64(aggregate.rawDocument.binary);
const meta = parseJsonObject(aggregate.rawDocument.meta);
const signedMeta = parseJsonObject(aggregate.document?.meta);
const signatures = aggregate.document?.signatures ?? [];
const certificates = signatures
.map((signature) => signature.signer_certificate)
.filter(Boolean);
const uniquenessHash =
aggregate.document?.hash ||
@@ -398,42 +455,21 @@ export const prepareDocumentArchive = async (
signerSurnames,
);
const signaturePayload = {
version: '1.0.0',
generated_at: new Date().toISOString(),
document: {
title: meta?.title ?? null,
full_title: aggregate.rawDocument.full_title ?? null,
binary_hash: aggregate.rawDocument.hash ?? null,
meta,
},
signed_document: aggregate.document
? {
hash: aggregate.document.hash,
doc_hash: aggregate.document.doc_hash,
meta_hash: aggregate.document.meta_hash,
version: aggregate.document.version,
meta: signedMeta ?? aggregate.document.meta ?? null,
// pdfHash сверяем с doc_hash из агрегата — отлавливаем рассинхрон байтов PDF и подписи.
if (aggregate.document?.doc_hash && pdfHash.toLowerCase() !== aggregate.document.doc_hash.toLowerCase()) {
console.warn(
'doc_hash агрегата не совпал с SHA-256 PDF — архив может не пройти проверку целостности',
{ docHash: aggregate.document.doc_hash, pdfHash },
);
}
: null,
blockchain: {
aggregate_hash: aggregate.hash ?? null,
},
signatures,
certificates,
pdf: {
filename: pdfName,
mime: 'application/pdf',
size: pdfBytes.byteLength,
sha256: pdfHash,
},
};
const manifest = encoder.encode(JSON.stringify(signaturePayload, null, 2));
const sigFile = buildSigFile(aggregate, pdfName, signedMeta);
const sigBytes = encoder.encode(JSON.stringify(sigFile, null, 2));
const sigName = sigFileNameFor(pdfName);
const archiveBytes = buildZipArchive([
{ name: pdfName, data: pdfBytes },
{ name: 'signature.txt', data: manifest },
{ name: sigName, data: sigBytes },
]);
return {
@@ -447,6 +483,7 @@ export const prepareDocumentPackageArchive = async (
packageAggregate: IDocumentPackageAggregate,
): Promise<{ blob: Blob; archiveName: string }> => {
const files: ZipEntry[] = [];
const manifestEntries: Array<{ name: string; document: string; signature: string }> = [];
const processedHashes = new Set<string>();
// Определяем имя папки на основе заявления
@@ -505,10 +542,6 @@ export const prepareDocumentPackageArchive = async (
const pdfBytes = decodeBase64(documentAggregate.rawDocument.binary);
const meta = parseJsonObject(documentAggregate.rawDocument.meta);
const signedMeta = parseJsonObject(documentAggregate.document?.meta);
const signatures = documentAggregate.document?.signatures ?? [];
const certificates = signatures
.map((signature) => signature.signer_certificate)
.filter(Boolean);
const uniquenessHash =
documentAggregate.document?.hash ||
@@ -534,46 +567,29 @@ export const prepareDocumentPackageArchive = async (
);
const uniquePdfName = `${folderName}/${pdfName}`;
const sigName = sigFileNameFor(pdfName);
const uniqueSigName = `${folderName}/${sigName}`;
const signaturePayload = {
version: '1.0.0',
generated_at: new Date().toISOString(),
document: {
title: meta?.title ?? null,
full_title: documentAggregate.rawDocument.full_title ?? null,
binary_hash: documentAggregate.rawDocument.hash ?? null,
meta,
},
signed_document: documentAggregate.document
? {
hash: documentAggregate.document.hash,
doc_hash: documentAggregate.document.doc_hash,
meta_hash: documentAggregate.document.meta_hash,
version: documentAggregate.document.version,
meta: signedMeta ?? documentAggregate.document.meta ?? null,
if (documentAggregate.document?.doc_hash && pdfHash.toLowerCase() !== documentAggregate.document.doc_hash.toLowerCase()) {
console.warn('doc_hash агрегата не совпал с SHA-256 PDF в пакете', {
docHash: documentAggregate.document.doc_hash,
pdfHash,
});
}
: null,
blockchain: {
aggregate_hash: documentAggregate.hash ?? null,
},
signatures,
certificates,
pdf: {
filename: pdfName,
mime: 'application/pdf',
size: pdfBytes.byteLength,
sha256: pdfHash,
},
};
const manifest = encoder.encode(JSON.stringify(signaturePayload, null, 2));
const signatureFileName = `${folderName}/${pdfName.replace('.pdf', '')}_signature.txt`;
const sigFile = buildSigFile(documentAggregate, pdfName, signedMeta);
const sigBytes = encoder.encode(JSON.stringify(sigFile, null, 2));
// Добавляем файлы в архив
// Добавляем файлы в архив + запись в манифест пакета
files.push(
{ name: uniquePdfName, data: pdfBytes },
{ name: signatureFileName, data: manifest }
{ name: uniqueSigName, data: sigBytes },
);
manifestEntries.push({
name: pdfName.replace(/\.pdf$/i, ''),
document: uniquePdfName,
signature: uniqueSigName,
});
} catch (error) {
console.error('Ошибка при обработке документа пакета:', error, documentAggregate);
@@ -584,6 +600,15 @@ export const prepareDocumentPackageArchive = async (
throw new Error('Не найдено ни одного документа для архивации');
}
// manifest.json — карта пакета для верификатора (тройки name/document/signature).
const packageManifest = {
v: '1.0',
generator: 'coopenomics-desktop',
created_at: new Date().toISOString(),
documents: manifestEntries,
};
files.push({ name: 'manifest.json', data: encoder.encode(JSON.stringify(packageManifest, null, 2)) });
// Создаем имя архива на основе имени папки
const archiveName = folderName;
+1
View File
@@ -48,6 +48,7 @@
"@wharfkit/contract": "^1.1.5",
"@wharfkit/session": "^1.4.0",
"@wharfkit/wallet-plugin-privatekey": "^1.1.0",
"canonicalize": "^2.0.0",
"cooptypes": "workspace:*",
"graphql": "^16.9.0",
"graphql-request": "^7.1.2",
+25 -6
View File
@@ -1,7 +1,11 @@
import type { IGeneratedDocument, ISignatureInfo, ISignatureInfoInput, ISignedChainDocument, ISignedDocument } from '../../types/document'
import { PrivateKey, PublicKey, Signature } from '@wharfkit/antelope'
import canonicalize from 'canonicalize'
import { Crypto } from '../crypto'
/** Актуальная версия алгоритма подписи для новых документов (детерминированная канонизация meta через JCS RFC 8785). */
export const CURRENT_DOCUMENT_VERSION = '1.1.0'
/**
* Класс для управления и подписания документов с использованием WIF-ключа.
*
@@ -64,15 +68,26 @@ export class Document {
version: string = '1.0.0',
): Promise<{ meta_hash: string, hash: string, signed_hash: string }> {
if (version === '1.0.0' || !version) {
// Legacy: недетерминированная канонизация — JSON.stringify(meta) зависит от порядка ключей.
const meta_hash = await Crypto.sha256(JSON.stringify(meta))
const hash = await Crypto.sha256(meta_hash + documentHash)
const signed_hash = await Crypto.sha256(hash + signed_at)
return { meta_hash, hash, signed_hash }
}
// Здесь можно добавить другие версии расчёта
// Например:
// if (version === '2') { ... }
if (version === '1.1.0') {
// Детерминированная канонизация meta через JCS (RFC 8785) — порядок ключей не влияет на meta_hash.
// Та же либа `canonicalize`, что в верификаторе документов → meta_hash совпадает байт-в-байт.
// Остальная цепочка (hash, signed_hash) и регистры — идентичны 1.0.0; меняется только источник meta_hash.
const canonicalMeta = canonicalize(meta)
if (canonicalMeta === undefined)
throw new Error('JCS: значение meta не сериализуемо (undefined/function/symbol)')
const meta_hash = await Crypto.sha256(canonicalMeta)
const hash = await Crypto.sha256(meta_hash + documentHash)
const signed_hash = await Crypto.sha256(hash + signed_at)
return { meta_hash, hash, signed_hash }
}
throw new Error(`Неизвестная версия алгоритма: ${version}`)
}
@@ -90,7 +105,7 @@ export class Document {
signatureId: number = 1,
existingSignedDocuments?: ISignedDocument[],
): Promise<ISignedDocument> {
const version = '1.0.0'
const version = CURRENT_DOCUMENT_VERSION
if (!this.wif)
throw new Error(`Ключ не установлен, выполните вызов метода setWif перед подписью документа`)
@@ -380,10 +395,14 @@ export class Document {
public static async compareDocuments(
signedDocument: ISignedDocument,
generatedDocument: IGeneratedDocument,
version: string = '1.0.0',
version?: string,
): Promise<{ isValid: boolean, differences: Record<string, { expected: string, actual: string }> }> {
// Версию алгоритма берём из самого подписанного документа (1.0.0 legacy / 1.1.0 JCS),
// если явно не передана — иначе старый дефолт 1.0.0 ломал бы сверку 1.1.0-документов.
const effectiveVersion = version ?? signedDocument.version ?? '1.0.0'
// Создаем неподписанную версию из сгенерированного документа
const unsignedDocument = await Document.createUnsignedDocument(generatedDocument, version)
const unsignedDocument = await Document.createUnsignedDocument(generatedDocument, effectiveVersion)
const differences: Record<string, { expected: string, actual: string }> = {}
+9
View File
@@ -1364,6 +1364,9 @@ importers:
'@wharfkit/wallet-plugin-privatekey':
specifier: ^1.1.0
version: 1.1.0(@wharfkit/session@1.6.1)
canonicalize:
specifier: ^2.0.0
version: 2.1.0
cooptypes:
specifier: workspace:*
version: link:../cooptypes
@@ -9132,6 +9135,10 @@ packages:
caniuse-lite@1.0.30001781:
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
canonicalize@2.1.0:
resolution: {integrity: sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==}
hasBin: true
canvg@3.0.11:
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
engines: {node: '>=10.0.0'}
@@ -29103,6 +29110,8 @@ snapshots:
caniuse-lite@1.0.30001781: {}
canonicalize@2.1.0: {}
canvg@3.0.11:
dependencies:
'@babel/runtime': 7.29.2