From 28ce608efe1fb8a58f54e71df7221d3ca6bdc963 Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 07:58:28 +0000 Subject: [PATCH 1/6] =?UTF-8?q?[794-4][@ant]=20feat(cooptypes):=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=BD=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=B9=20=D1=82=D0=B8=D0=BF=20SigFile=20v2.0=20+=20PackageManif?= =?UTF-8?q?est=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=B5=D1=80=D0=B8=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=BE=D1=80=D0=B0=20=E2=80=94=20=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D1=8B=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B0=20.si?= =?UTF-8?q?g=20(=D1=86=D0=B5=D0=BB=D0=BE=D1=81=D1=82=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C+=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D1=8C+OID),?= =?UTF-8?q?=20=D1=87=D1=82=D0=BE=D0=B1=D1=8B=20desktop-=D1=8D=D0=BA=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=20=D0=B8=20=D0=B2=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80=20=D1=81=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=B0=D0=BB=D0=B8=D1=81=D1=8C=20=D0=BD=D0=B0=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/cooperative/document/index.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/components/cooptypes/src/cooperative/document/index.ts b/components/cooptypes/src/cooperative/document/index.ts index f66e6ed5ecf..e307484c795 100644 --- a/components/cooptypes/src/cooperative/document/index.ts +++ b/components/cooptypes/src/cooperative/document/index.ts @@ -238,3 +238,75 @@ export interface IGetComplexDocuments2 { export interface IComplexAgenda2 extends IAgenda { documents: IComplexDocument2 } + +/** + * Режим канонизации `meta` при вычислении meta_hash (см. SigFile v2.0). + * 'legacy-node-stringify' — текущая логика SDK: SHA-256(JSON.stringify(meta)). + * 'jcs-1.0' — RFC 8785 (задел; реальные документы пока legacy). + */ +export type CanonicalizationMode = '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 { + 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-пакета документов (массовая проверка). Перечисляет тройки + * .pdf / .sig / опц. .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[] +} From 067718cf1f88dbefd66b930d3adfead4df41e9d7 Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 08:02:59 +0000 Subject: [PATCH 2/6] =?UTF-8?q?[794-4][@ant]=20feat(desktop):=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B2=20.sig=20v2.0=20+?= =?UTF-8?q?=20manifest.json=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20signa?= =?UTF-8?q?ture.txt=20=E2=80=94=20=D1=81=D0=B0=D0=BC=D0=BE=D0=B4=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B0=D1=80=D1=85=D0=B8=D0=B2=20(canonical=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20=D0=B5=D1=81=D1=82=D1=8C,=20legacy-=D0=BA?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F,?= =?UTF-8?q?=20=D0=B2=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=81=D0=B5=D1=80=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=82?= =?UTF-8?q?)=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=B5=D1=80=D0=B8=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=BE=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/document/prepareDocumentArchive.ts | 158 ++++++++++-------- 1 file changed, 84 insertions(+), 74 deletions(-) diff --git a/components/desktop/src/shared/lib/document/prepareDocumentArchive.ts b/components/desktop/src/shared/lib/document/prepareDocumentArchive.ts index a5edcefe172..cebdbec3ca4 100644 --- a/components/desktop/src/shared/lib/document/prepareDocumentArchive.ts +++ b/components/desktop/src/shared/lib/document/prepareDocumentArchive.ts @@ -355,6 +355,52 @@ const buildArchiveName = ( return sanitizeName(`${base}${hashSuffix}${signerSuffix}`); }; +// OID криптопримитивов для самодостаточного .sig (см. 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-подпись `.sig` v2.0 из агрегата документа. + * + * canonical-значения берём КАК ЕСТЬ из `aggregate.document`: `doc_hash` приходит из фабрики + * в верхнем регистре (SHA-256 байтов PDF), `meta_hash`/`hash` — из SDK в нижнем. Верификатор + * сверяет хэш-цепочку именно по этим строкам (C3 конкатенирует их без нормализации регистра). + * + * canonicalization='legacy-node-stringify': SDK считает meta_hash как SHA-256(JSON.stringify(meta)). + */ +const buildSigFile = ( + aggregate: IDocumentAggregate, + pdfName: string, + signedMeta: Record | null, +): Record => { + const doc = aggregate.document; + const signatures = doc?.signatures ?? []; + return { + v: '2.0', + canonicalization: 'legacy-node-stringify', + 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: signature.signed_at, + signed_hash: signature.signed_hash, + signer_certificate: signature.signer_certificate ?? null, + issuer_signature: null, + })), + }; +}; + +/** Имя `.sig` рядом с PDF: .sig (тот же базовый стем, что у PDF). */ +const sigFileNameFor = (pdfName: string) => `${pdfName.replace(/\.pdf$/i, '')}.sig`; + export const prepareDocumentArchive = async ( aggregate: IDocumentAggregate, ): Promise<{ blob: Blob; archiveName: string; pdfName: string }> => { @@ -365,10 +411,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 +440,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 +468,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(); // Определяем имя папки на основе заявления @@ -505,10 +527,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 +552,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 +585,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; From 4a5f41d15e84f45dde2c8eae028232141c5a40d3 Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 18:46:49 +0000 Subject: [PATCH 3/6] =?UTF-8?q?[794-4][@ant]=20feat(sdk):=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B8=201.1.0=20=D1=81=20=D0=B4=D0=B5=D1=82=D0=B5=D1=80=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=B0=D0=BD=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B5=D0=B9=20meta=20(JCS=20RFC=208785)=20=E2=80=94=20me?= =?UTF-8?q?ta=5Fhash=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=91?= =?UTF-8?q?=D1=82=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B5=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BE=D1=82=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=B9;=201.0.0=20=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=91=D1=82=D1=81=D1=8F=20legacy;=20compareDocumen?= =?UTF-8?q?ts=20=D0=B1=D0=B5=D1=80=D1=91=D1=82=20=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D1=8E=20=D0=B8=D0=B7=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=20(=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=20=D0=B0=D0=B2=D1=82=D0=BE-=D0=BA=D0=BE=D1=80=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B5=D0=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/sdk/package.json | 1 + components/sdk/src/classes/document/index.ts | 31 ++++++++++++++++---- pnpm-lock.yaml | 9 ++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/components/sdk/package.json b/components/sdk/package.json index c78b69cbd82..be181ffc6c5 100644 --- a/components/sdk/package.json +++ b/components/sdk/package.json @@ -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", diff --git a/components/sdk/src/classes/document/index.ts b/components/sdk/src/classes/document/index.ts index e00de135680..c4d2d9eb444 100644 --- a/components/sdk/src/classes/document/index.ts +++ b/components/sdk/src/classes/document/index.ts @@ -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 { - 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 }> { + // Версию алгоритма берём из самого подписанного документа (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 = {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26201ea9e31..e95887691ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 From da55cbac089da9191b9465256800ab4d8daec844 Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 18:46:49 +0000 Subject: [PATCH 4/6] =?UTF-8?q?[794-4][@ant]=20feat(cooptypes):=20canonica?= =?UTF-8?q?lizationForVersion(version)=20=E2=80=94=20=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=20=D1=81=D0=BE=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=B8=D1=8F=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20=E2=86=92=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B6=D0=B8=D0=BC=20=D0=BA=D0=B0=D0=BD=D0=BE=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20(.sig=20=D0=B4=D0=BB=D1=8F=20d?= =?UTF-8?q?esktop=20=D0=B8=20=D0=B2=D0=B5=D1=80=D0=B8=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D1=80=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cooptypes/src/cooperative/document/index.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/components/cooptypes/src/cooperative/document/index.ts b/components/cooptypes/src/cooperative/document/index.ts index e307484c795..60de9fdca21 100644 --- a/components/cooptypes/src/cooperative/document/index.ts +++ b/components/cooptypes/src/cooperative/document/index.ts @@ -241,11 +241,20 @@ export interface IComplexAgenda2 extends IAgenda { /** * Режим канонизации `meta` при вычислении meta_hash (см. SigFile v2.0). - * 'legacy-node-stringify' — текущая логика SDK: SHA-256(JSON.stringify(meta)). - * 'jcs-1.0' — RFC 8785 (задел; реальные документы пока legacy). + * '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. */ From c1d1b0897338e1adfbb85702594e94e1ea65398f Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 18:46:49 +0000 Subject: [PATCH 5/6] =?UTF-8?q?[794-4][@ant]=20feat(desktop):=20.sig=20?= =?UTF-8?q?=D0=BF=D0=B8=D1=88=D0=B5=D1=82=20canonicalization=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=20(1.1.0=E2=86=92jcs?= =?UTF-8?q?-1.0)=20+=20signed=5Fat=20=D1=87=D0=B8=D1=81=D1=82=D0=B8=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B4=D0=BE=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D0=B0=20=D0=B1=D0=B0=D0=B9=D1=82-=D0=B2-?= =?UTF-8?q?=D0=B1=D0=B0=D0=B9=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/document/prepareDocumentArchive.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/components/desktop/src/shared/lib/document/prepareDocumentArchive.ts b/components/desktop/src/shared/lib/document/prepareDocumentArchive.ts index cebdbec3ca4..7c5d021f1bf 100644 --- a/components/desktop/src/shared/lib/document/prepareDocumentArchive.ts +++ b/components/desktop/src/shared/lib/document/prepareDocumentArchive.ts @@ -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 — нет риска сдвига часового пояса. Гарантирует, что + * .sig несёт байт-в-байт ту строку, что вошла в signed_hash, и C4-проверка проходит без нормализации. + */ +const canonicalSignedAt = (s: string | null | undefined): string => + (s ?? '').replace(/\.\d+/, '').replace(/[zZ]$/, ''); + type ZipEntry = { name: string; data: Uint8Array; @@ -366,7 +375,9 @@ const SIG_HASH_OID_SHA256 = '2.16.840.1.101.3.4.2.1'; * в верхнем регистре (SHA-256 байтов PDF), `meta_hash`/`hash` — из SDK в нижнем. Верификатор * сверяет хэш-цепочку именно по этим строкам (C3 конкатенирует их без нормализации регистра). * - * canonicalization='legacy-node-stringify': SDK считает meta_hash как SHA-256(JSON.stringify(meta)). + * Режим канонизации выбирается по версии подписи документа: + * 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, @@ -377,7 +388,7 @@ const buildSigFile = ( const signatures = doc?.signatures ?? []; return { v: '2.0', - canonicalization: 'legacy-node-stringify', + 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' }, @@ -390,7 +401,7 @@ const buildSigFile = ( signatures: signatures.map((signature) => ({ public_key: signature.public_key, signature: signature.signature, - signed_at: signature.signed_at, + signed_at: canonicalSignedAt(signature.signed_at), signed_hash: signature.signed_hash, signer_certificate: signature.signer_certificate ?? null, issuer_signature: null, From b3ed693db67be13d7c94e94a4edd4df6d4b8ef0d Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 19:01:19 +0000 Subject: [PATCH 6/6] =?UTF-8?q?[794-4][@ant]=20feat(desktop):=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B8=20.sig=20=E2=86=92=20.coopsig=20=E2=80=94=20=D1=87=D1=82?= =?UTF-8?q?=D0=BE=D0=B1=D1=8B=20=D0=9E=D0=A1=20=D0=BD=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=BB=D0=B0=D0=B3=D0=B0=D0=BB=D0=B0=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D1=8C=20=D0=B2=20=D0=9A=D1=80=D0=B8?= =?UTF-8?q?=D0=BF=D1=82=D0=BE=D0=9F=D1=80=D0=BE=20(=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=B8=D0=BC=D1=8B=D0=B9=20?= =?UTF-8?q?secp256k1-JSON=20vs=20=D0=93=D0=9E=D0=A1=D0=A2-CMS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/lib/document/prepareDocumentArchive.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/components/desktop/src/shared/lib/document/prepareDocumentArchive.ts b/components/desktop/src/shared/lib/document/prepareDocumentArchive.ts index 7c5d021f1bf..ca06b437810 100644 --- a/components/desktop/src/shared/lib/document/prepareDocumentArchive.ts +++ b/components/desktop/src/shared/lib/document/prepareDocumentArchive.ts @@ -5,7 +5,7 @@ import { getShortNameFromCertificate } from '../utils/getNameFromCertificate'; /** * Восстанавливает ровно подписанный формат signed_at (SDK подписывает ISO UTC без дробных секунд и без Z). * Чистая строковая операция без reparse через Date — нет риска сдвига часового пояса. Гарантирует, что - * .sig несёт байт-в-байт ту строку, что вошла в signed_hash, и C4-проверка проходит без нормализации. + * .coopsig несёт байт-в-байт ту строку, что вошла в signed_hash, и C4-проверка проходит без нормализации. */ const canonicalSignedAt = (s: string | null | undefined): string => (s ?? '').replace(/\.\d+/, '').replace(/[zZ]$/, ''); @@ -364,12 +364,12 @@ const buildArchiveName = ( return sanitizeName(`${base}${hashSuffix}${signerSuffix}`); }; -// OID криптопримитивов для самодостаточного .sig (см. cooptypes SigFile v2.0). +// 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-подпись `.sig` v2.0 из агрегата документа. + * Формирует detached-подпись (файл `.coopsig`, формат SigFile v2.0) из агрегата документа. * * canonical-значения берём КАК ЕСТЬ из `aggregate.document`: `doc_hash` приходит из фабрики * в верхнем регистре (SHA-256 байтов PDF), `meta_hash`/`hash` — из SDK в нижнем. Верификатор @@ -409,8 +409,12 @@ const buildSigFile = ( }; }; -/** Имя `.sig` рядом с PDF: .sig (тот же базовый стем, что у PDF). */ -const sigFileNameFor = (pdfName: string) => `${pdfName.replace(/\.pdf$/i, '')}.sig`; +/** + * Имя файла подписи рядом с PDF: .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,