Впровадження оригіналу фото в контракт та анатомія «самодоказуваного» PDF в застосунку для підпису договорів
Я Дійкун Дмитро, незалежний розробник (Diikun). Роблю Pakto — застосунок для підписання документів між двома сторонами, де підпис створюється апаратним ключем пристрою під біометрією. Соло: протокол, криптографія, клієнт під iOS і Android, бекенд, верифікатор. У першій статті я розбирав ядро — взаємний офлайн-підпис двома телефонами без сервера й без центру сертифікації. Ця стаття — про долю фотографії: що з нею відбувається між натисканням спуску і фінальним PDF, і чому цей PDF можна переслати будь-кому й перевірити без мене, без мого сервера і без інтернету.
Почну з чесної межі, бо саме її люблять перевіряти в коментарях (і правильно роблять).
Питання «а це фото справжнє чи його підсунули з рутованого телефона?» — це не те питання, на яке я відповідаю криптографічно. Жодна мобільна програма без довіреного апаратного каналу камери на це чесно відповісти не може: на пристрої з повним контролем над ОС зловмисник підміняє весь camera-стек, і будь-який аналіз кадрів аналізує рівно ті байти, які він і контролює. Це поза моделлю загроз — так само, як примус користувача, що сам тисне біометрію.
Питання, на яке Pakto відповідає повністю і доказово, інше — і саме йому присвячена ця стаття:
Це фото, прив’язане до цього договору, цього часу і цих підписантів, — після підписання не змінювалося. І весь оригінал доказу лежить усередині одного PDF-файлу, який перевіряється без мене.
Ось до цього і будемо йти. Але спершу — коротко про те, що народжується разом із фото, бо без цього PDF нічого не «носитиме».
Передісторія за 60 секунд: що фото несе в собі ще до PDF
Коли користувач тисне спуск, фотографія не просто зберігається. За один прохід народжується пакет, кожен елемент якого потім стане частиною доказу.
Watermark випалюється в пікселі. Знятий JPEG декодується, зверху малюється банер (PAKTO, дата й час у UTC), і зображення перекодовується в новий JPEG уже разом із банером. Watermark не «поверх» у в’ювері й не окремим шаром — він фізично в тих байтах, чий хеш ми зараз порахуємо. Стерти дату, не зруйнувавши хеш, неможливо.
Рахується хеш саме цих байтів — sha256Hash = SHA-256(watermarkedJpegBytes) — і окремо fingerprint, що прив’язує кадр до пристрою й сесії: SHA-256(bytes[0..512] ‖ deviceId ‖ nonce ‖ capturedAt), де nonce — 32 випадкові байти від криптогенератора.
Перед спуском ще працює liveness-евристика — три незалежні сигнали (часова дисперсія яскравості, блочний контраст між блоками кадру, анти-муар), а рішення ухвалюється голосуванням у ковзному вікні: «3 live-голоси з 5 кадрів», щоб випадковий блик в одному кадрі не перемикав статус. Скажу відверто: це фільтр вартості проти дешевої атаки «піднести чужий екран до камери», а не криптографічний доказ реальності зйомки. Рутований пристрій він не покриває. Уся доказова вага — далі, і вона не евристична.
Підсумок цього розділу: у PDF заходить не «картинка», а картинка з випаленим часом, власним SHA-256 і відбитком пристрою. Тепер — головне. ( заради якого все й робилось )
Ядро: PDF, який носить оригінал усередині себе
Ось частина, заради якої й затівалася стаття.
У багатьох системах докази живуть «поруч»: PDF окремо, картинки окремо, база окремо. Втратив вкладення — втратив доказ. Переслав колезі лише PDF — переслав порожню оболонку, а справжні дані лишилися на сервері, до якого в нього немає доступу. У Pakto інакше, і це свідоме архітектурне рішення, а не випадковість.
По-перше, фото видно в самому PDF
На сторінці доказів рендериться смуга знімків, і під кожним — його SHA-256 (усічений для читабельності). Людина, яка просто відкрила файл у звичайному переглядачі, бачить і саму картинку, і її криптографічний відбиток поруч. Нічого встановлювати не треба — це вже видно очима.

По-друге — і це головне — повний оригінал зашитий усередину PDF-файлу
Після генерації сторінок у кінець файлу дописується прихований блок:
...(байти PDF-документа)...
START_TMARK_JSON{...повний JSON payload...}END_TMARK_JSON
У цьому JSON — усе: поля договору, публічні ключі обох сторін, обидва підписи, усі хеші і самі фотографії в base64. Не посилання на них, не мініатюри, не «прев’ю» — повні байти оригіналів, тих самих, чий SHA-256 стоїть під підписом сторони А.
Тобто один PDF-файл одночасно є:
- людиночитаним документом — його відкриває будь-хто, бачить договір, фото, хеші;
- машинним контейнером доказу — усередині лежить повний оригінал усього, що потрібно для незалежної криптоперевірки.
Що це дає на практиці
Ви пересилаєте PDF другій людині — у месенджері, поштою, роздруковуєте й скануєте назад (ні, скан не підійде — але файл), кладете в архів на десять років. У отримувача на руках виявляється не «картинка договору», а самодостатній контейнер. Йому не потрібні:
- ні я як автор,
- ні мій сервер,
- ні ваше вихідне вкладення з фотографіями,
- ні інтернет у момент перевірки.
Усе — в PDF. Це прямий наслідок філософії з першої статті: якщо підпис перевіряється офлайн без CA, то й докази, які він покриває, мають їхати разом із ним, а не лишатися на чиємусь сервері. Інакше офлайн-підпис безглуздий — ви прибрали залежність від мережі в момент підпису, але повернули її в момент перевірки.
Чому блок дописується в кінець, а не через «правильний» PDF-attachment (/EF)
Питання законне: у PDF є штатний механізм вкладень — embedded files через /EF і дерево /Names. Чому не він?
Прагматика і стійкість.
Дописування в хвіст файлу:
- не залежить від капризів конкретного PDF-рендерера — не треба сподіватися, що чужий в’ювер коректно збереже дерево вкладень;
- читається тривіальним лінійним пошуком байтового маркера — верифікатору не потрібен повноцінний парсер PDF зі знанням xref-таблиць, cross-reference streams і об’єктної моделі; достатньо знайти
START_TMARK_JSONі взяти все доEND_TMARK_JSON; - не ламається, якщо хтось відкриє й перезбереже PDF у сторонньому переглядачі, який любить перетасувати структуру об’єктів, переписати xref чи викинути «невідомі» вкладення. Хвіст файлу такі операції зазвичай не чіпають.
Витяг payload у коді — це буквально «знайти маркер, розпарсити JSON між маркерами»:
extractHiddenPayload(pdfBytes):
startIdx = знайти "START_TMARK_JSON"
endIdx = знайти "END_TMARK_JSON"
json = pdfBytes[startIdx.. endIdx]
→ payload (поля, ключі, підписи, хеші, фото в base64)
Простий механізм, який важко зламати випадково, — це надійніше за «правильний», але крихкий.
Чому вкладений оригінал не можна тихо підмінити
Здавалося б: якщо оригінал просто дописаний у хвіст текстом — що заважає його там і підправити? Відповідь: підпис, який його покриває.
Хеш кожного фото входить у ланцюг, що завершується підписом сторони А. Спрощено:
sha256(кожного фото) → evidenceHashesJson → evidencePhotosHash
↓
...разом з полями договору → finalHash
contentHash (зміст + фото-хеші) —— підписує А ключем у Secure Enclave
evidencePhotosHash рахується як SHA-256 від конкатенації відсортованих хешів окремих знімків — сортування робить результат детермінованим незалежно від порядку фото в масиві. Будь-який верифікатор отримає те саме значення.
Практичний наслідок: змініть хоч один байт вкладеної фотографії — зміниться її SHA-256, зміниться агрегований хеш, зміниться finalHash, і перевірка підпису впаде. Підмінити фото, не зламавши підпис, означає або зламати SHA-256, або підробити ECDSA-підпис без приватного ключа з апаратного модуля.
І окрема деталь для тих, хто дійде до перевірки руками. Витягти payload можна у двох режимах:
fromJson— просто розпарсити, довірливо;fromJsonVerified— розпарсити з перевіркою цілісності фото: перерахувати SHA-256 кожного вкладеного зображення і звірити з тим, що зашито в payload. Якщо хоч один знімок не сходиться — метод кидає помилку, і такий PDF не проходить як валідний.
Тобто «фото на сторінці» і «фото в прихованому оригіналі» не можуть розійтися непомітно: розбіжність ловиться на витягу, ще до криптоперевірки підписів.
Якір — це зміст, а не байти PDF
Останнє, що важливо для доказовості й що часто плутають.
Криптографічним якорем є текстові канонічні поля (contentHash, finalHash) плюс хеші фотографій, а не байти самого PDF. Хеші PDF-файлу (parentPdfHash, finalPdfHash, ltvPdfHash) навмисно виключені з finalHash.
Причина та сама, що я розписував у першій статті: PDF недетермінований — різні рендерери дають різні байти за ідентичного змісту (інший порядок метаданих, шрифтів). Якби доказ спирався на хеш PDF, він розсипався б від найпростішого переекспорту, і ви отримували б хибні провали перевірки на рівному місці.
Тому будь-хто може перегенерувати PDF будь-коли — інша тема оформлення, інша мова інтерфейсу, інший розмір — і підписи з хешами фото від цього не постраждають. Вони прив’язані до змісту й до самих зображень, а не до конкретної байтової розкладки файлу. Якщо в суді опонент пред’явить «змінений PDF» — відповідь та сама, що й у першій статті: хеш PDF ніколи не був частиною доказу; доказ — це підпис над змістом і над хешами фото, який відтворюється незалежно від файлу-відображення.
Чому саме PDF, а не QR: оригінал має доїхати до підпису, а не після
Тут постає закономірне питання. У першій статті весь обмін між сторонами йшов через QR-коди — анімований оптичний канал. Чому ж для договорів із фотографіями канал інший?
Бо фотографія фізично не влазить у QR. Навіть анімований fountain-код заточений під компактну структуру (поля, хеші, підписи, токен атестації) — а не під сотні кілобайтів base64-зображень. І, що важливіше, тут інша логіка доставки.
А чому не гнати фото через QR? Порахуймо чесно для дешевих телефонів. Реальна полоса оптичного каналу «екран → камера» — це добуток двох малих чисел: скільки кадрів на секунду телефон встигає обробити (не зняти, а декодувати QR + прогнати через fountain — на бюджетному Android це ~7 кадрів/с, звідси й цифра з першої статті) на скільки байтів реально читається з одного кадру камерою так собі (~675 байтів, як у мене й стоїть). Разом — приблизно 5 КБ/с, зрідка більше. Один watermarked-JPEG після перекодування — це сотні КБ; з надмірністю fountain-коду це хвилина-півтори «тримай телефон рівно, не рони кадри» на одне фото, а на п’ять — до
5–8 хвилин. Для UX це жах: жодна людина не втримає два телефони навпроти хвилину, кадри губляться, доводиться починати заново. Висновок: оптика чудова для компактної криптоструктури (підписи, хеші, токен атестації) і погана для «важких» даних. Тому фото їде через PDF, а QR лишається для того, для чого фізично працює.
У Pakto діє правило: якщо в договорі є фото — підписати його можна лише через PDF. Не через QR. І ось чому це принципово.
Уявімо, що фото їхало б до другої сторони після підпису — як частина фінального артефакту. Тоді сторона Б підписала б договір, не бачивши оригіналів того, що вона засвідчує. Вона поставила б підпис під хешем, за яким для неї — «кіт у мішку». Це руйнує весь сенс доказу: підпис має покривати те, що людина реально бачила й прийняла, а не те, що їй покажуть потім.
Тому потік будується навпаки:
- Сторона А формує договір із фотографіями, і Pakto генерує PDF з уже вшитим усередину оригіналом (той самий
START_TMARK_JSON-блок із фото в base64). - Цей PDF передається другій стороні будь-яким каналом — месенджер, пошта, AirDrop, Bluetooth, звичайний файловий обмін. Канал не має значення, бо всі дані вже в файлі.
- Друга сторона відкриває цей PDF через Pakto. Застосунок витягує вшитий payload, показує людині і договір, і самі оригінали фотографій — не мініатюри й не хеші, а повні зображення, які підписує.
- Тільки після цього сторона Б ставить підпис — онлайн або офлайн, своїм ключем у захищеному модулі під біометрією.
Різниця тонка, але вирішальна: оригінал доїжджає до підписанта до підпису, а не після. Сторона Б підписує рівно те, що бачила на власні очі, у повній якості, з усіма деталями. Її підпис від самого початку покриває справжні зображення, а не обіцянку показати їх пізніше.
І це вкладається в ту саму філософію самодостатнього файлу. PDF тут — не просто «підсумковий документ на згадку». Він робочий носій: він же переносить оригінали між сторонами, він же дає їх побачити перед підписом, він же стає запечатаним доказом після. Один файл на весь життєвий цикл — від «ось що я пропоную підписати» до «ось підписаний доказ, перевір сам».
Окремий режим: фіксація факту без другої сторони
Досі йшлося про договір між двома сторонами. Але та сама логіка працює й для одного підписанта — це окремий режим «фото-доказу».
Одна людина робить знімок (передача майна, стан речі, показник лічильника, пошкодження — що завгодно, що треба зафіксувати «на зараз») і підписує його власним ключем у захищеному модулі під біометрією, без будь-якої другої сторони. Далі — та сама схема: watermark у пікселях, SHA-256 знімка під підписом, повний оригінал усередині PDF, перевірка офлайн будь-ким.
Чесно про межу цього режиму. Тут немає взаємної згоди — отже, немає й аргументу «друга сторона підписала те, що бачила». Односторонній підпис доказує вужче, але важливе: цей ключ зафіксував цей знімок у цей момент, і відтоді знімок не змінювався. Це не договір — це самозасвідчена фіксація факту з перевірюваною цілісністю й часовою прив’язкою. Для «я зафіксував стан речі до того, як його хтось оспорить» цього достатньо; для двостороннього зобов’язання — ні, і тоді працює повний протокол із розділів вище.
Підсумок: що саме доказує «фото-доказ» Pakto
Чесний баланс — окремо залізне, окремо фільтр.
Доказово, математично, без зірочок:
- Фотографія прив’язана до конкретного договору, часу й підписантів через ланцюг хешів під підписом.
- Після підписання фото змінити не можна — будь-яка зміна ловиться перевіркою.
- Оригінал не губиться і не залежить від сервера: він усередині PDF, повними байтами, і другий отримувач має самодостатній доказ на руках.
- «Фото на сторінці» не може розійтися з «фото в оригіналі» —
fromJsonVerifiedце ловить. - Сторона Б підписує оригінали, які бачила до підпису — PDF доставляє повні зображення перед підписанням, а не після.
- Усе перевіряється офлайн, незалежним верифікатором, без довіри до мене — рівно як і підписи.
Інженерний фільтр, не гарантія:
- Liveness піднімає вартість дешевої атаки «переснять екран», але не є криптодоказом реальності зйомки і не покриває рутований пристрій.
Pakto не перетворює телефон на нотаріуса, який присягає «я на власні очі бачив, як це знято». Він робить чесніше і корисніше: перетворює фотографію на запечатаний доказ усередині PDF, який неможливо тихо підправити заднім числом і який кожен може перевірити сам, маючи лише сам файл.
Watermark каже людині, коли знято. Хеш під підписом каже машині, що відтоді не чіпали. Прихований оригінал у PDF каже отримувачу: тобі не потрібен автор, щоб це перевірити.
Як і минулого разу — найцінніша критика та, що показує дірку, якої я не бачу. Пишіть.
P.s
Кожна деталь окремо не нова. Разом, офлайн, на телефонах, які вже в людей у руках — такої комбінації я не бачив. Якщо ви бачили — киньте посилання, мені справді цікаво.
5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівЧи може система бути використана в ланцюжку підписів: умовно Менеджер А -> Головний бухгалтер В — > Директор С?
Чи може система бути використана, коли підписантів більше 2: збори акціонерів, 10 рівноправних підписантів?
Дякую за питання, спочатку розділю, що «архітектурно закладено», а що «реалізовано в застосунку сьогодні». Розділю, бо ваші два приклади — різні за складністю.
Ланцюжок з різними ролями (Менеджер → Головбух → Директор). Так, це лягає на схему природно. Ядро побудоване на послідовному зчепленні: підпис кожної наступної сторони формується над геш-якорем, що включає публічний ключ і підпис попередньої.
Тобто підпис бухгалтера покриває підпис Менеджера, підпис Директора — обидва попередні. Це дає саме те, що потрібно для рольового ланцюга: фіксований порядок (Бухгалтер підписує після Менеджера, і його підпис містить у собі підпис Менеджера. Директор підписує останнім, і його підпис містить підписи обох — і Менеджера, і Бухгалтера . А вплести чужий підпис можна лише після того, як він уже існує. Тому порядок Менеджер → Бухгалтер → Директор вбудований у самі підписи, і переставити його неможливо.), і будь-яка спроба переставити чи вирізати ланку ламає зчеплення. Математично це те саме N-розширення двостороннього випадку, просто з N=3.
10 рівноправних підписантів (збори акціонерів). Тут чесніше сказати так: сама криптографія масштабується — послідовний ланцюг на N сторін будується тим самим механізмом зчеплення. Але «рівноправність» додає не криптографічні, а протокольні й UX-питання, яких у двосторонньому кейсі немає: у рівноправних немає природного порядку підписання, а зчеплення послідовне за побудовою; треба вирішити чергу, передачу інформації між 10 пристроями, поведінку при відмові одного з підписантів, і чи кожен наступний зобов’язаний бачити підписи всіх попередніх. Це вже інший продукт за UX, а не «те саме, але 10 разів».
Так що, послідовний ланцюг ролей (ваш перший приклад) — архітектурно рідний сценарій; N рівноправних підписантів — закладено в моделі, але це окрема реалізація з власними протокольними рішеннями, і в поточному застосунку її немає.
З рівноправними підписантами схема може бути з не N рівних, а К+1 — учасники + секретар.
1. Секретар фоткає девайси учасників по черзі. Якраз момент, щоб відмовитись від процедури.
2. Всі фоткають секретарський девайс (або QR на телевізорі). Всі отримали підаписи всіх.
Це можлива організаційна схема. Але очевидно, що доволі специфічна фіча, щоб мати її в перший день релізу.
О, це гарно — ви фактично розв’язали саме те, що я думав: як дати рівноправність, не ламаючи послідовне зчеплення. Ваша ідея — не N рівних, а топологія «зірка»: K учасників + секретар-агрегатор, дві фази замість N² обмінів між усіма. І окремий плюс, що фаза збору — природна точка виходу: не хочеш підписувати — виходиш до того, як щось запечатано. Це елегантно.
Одне питання, яке тут постає і яке мене найбільше цікавить — зчеплення. У двосторонньому кейсі вся сила в тому, що підпис Б містить підпис А, тому їх не роз’єднати. У вашій зірці треба вирішити, що саме підписує кожен учасник:
— якщо кожен підписує лише зміст (не бачачи чужих підписів) — рівноправність є, але підписи стають незалежними, їх можна «переклеїти» на інший документ, і невіддільність втрачається;
Проблема, якщо кожен підписує ТІЛЬКИ текст і не бачить чужих підписів- підписи виходять незалежні один від одного. А це відкриває дві неприємні речі.
Перша: підпис можна перенести з іншого документа. Уявімо, Іван колись підписав папір з таким самим текстом — на інших зборах, з іншого приводу. Його підпис під тим текстом і під нашим «Рішенням № 1» буде математично однаковий. Тобто хтось може взяти старий підпис Івана й підкласти в набір під новим рішенням, де Іван узагалі не голосував. Перевірка скаже «підпис валідний» — бо текст той самий. А Івана там і близько не було.
Друга: можна зібрати «кворум», якого ніколи не існувало. Раз підписи незалежні, я можу назбирати п’ять окремих підписів під однаковим текстом, зібраних у різний час і за різних обставин, і подати це як «одностайне рішення зборів такого-то числа». Хоча насправді ці п’ятеро ніколи не сиділи в одній кімнаті й нічого спільно не вирішували — просто кожен колись підмахнув такий самий текст.
Тобто набір незалежних підписів доводить лише «кожен із цих людей колись підписував такий текст», а не «саме ці люди зібралися й разом ухвалили саме це рішення». Для зборів акціонерів це якраз втрата найголовнішого — колективності. Тому й потрібне зчеплення: щоб кожен підпис ніс у собі підписи інших, і його не можна було ні перенести, ні зібрати докупи заднім числом.
— якщо кожен має покрити підписом ще й підписи інших — тоді, схоже, ваша двофазна схема потребує третьої фази: раунд 1 усі підписують зміст → секретар збирає → роздає → раунд 2 усі підписують уже з чужими підписами всередині. Тоді зчеплення повне, але це +1 обмін.
Тобто ваша схема організаційно робоча вже зараз, а «наскільки сильним буде зчеплення» — залежить від того, скільки раундів ми готові попросити людей провести в кімнаті.
І тут повністю згоден з вашим фіналом: це специфічна фіча, не для першого дня релізу. Двосторонній кейс покриває 95% реальних сценаріїв (договір, передача, розписка), а збори з рівноправними — це вже окремий продукт зі своїм UX. Але як напрям — ви щойно накидали найчистіший підхід, що я бачив. Дякую, забираю в нотатки.
Не певен в математичній строгості, але пара уточнень:
— ідея з К+1 кворумом в тому, що, так, учасники підписують лише документ, а секретар — документ + К підписів. Учасники з телефона секретаря мають отримати «однаковий» пакет всіх+секретар.
— чи може існувати «такий самий» документ по-байтово, але із минулого? Ні, камера секретаря випалює сьогоднішню дату в байтах.
— чи може документ почати існувати в минулому, отримати підписи одних учасників, а сьогодні отримати підписи решти + секретар фіналізує? Здається — так. Але не факт, що процедура, роздягнута в часі на дні, є нелегітимною для учасників.
В цілому ця схема про певну довіру до секретаря, як оркестратора всієї процедури. Хоч це і дивно звучить для процедури, де всі не довіряють всім.