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:
@@ -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,
|
||||
}
|
||||
: null,
|
||||
blockchain: {
|
||||
aggregate_hash: aggregate.hash ?? null,
|
||||
},
|
||||
signatures,
|
||||
certificates,
|
||||
pdf: {
|
||||
filename: pdfName,
|
||||
mime: 'application/pdf',
|
||||
size: pdfBytes.byteLength,
|
||||
sha256: pdfHash,
|
||||
},
|
||||
};
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
: null,
|
||||
blockchain: {
|
||||
aggregate_hash: documentAggregate.hash ?? null,
|
||||
},
|
||||
signatures,
|
||||
certificates,
|
||||
pdf: {
|
||||
filename: pdfName,
|
||||
mime: 'application/pdf',
|
||||
size: pdfBytes.byteLength,
|
||||
sha256: pdfHash,
|
||||
},
|
||||
};
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }> = {}
|
||||
|
||||
|
||||
Generated
+9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user