setSourceFile( StreamReader::createByString( $pdf_bytes ) ); return $n >= 1; } catch ( Throwable $e ) { return false; } } /** * Step 1 — affixes the signature on each document and computes the * fingerprint of the signed render. Since sealing is not yet frozen in the * database, the timestamp is supplied by the caller. * * @param array $documents Documents (objects, ordered). * @param array $signatures document_id => stroke (PNG dataURL or text). * @param string $sealed_at_utc UTC sealing timestamp. * @param string $page_mode Signature placement (Free: last page only). * @return array { signes: string[] (ordered), hashes: array(document_id => sha256) } * @throws RuntimeException If a source document is unreadable. */ public static function sign_documents( array $documents, array $signatures, $sealed_at_utc, $page_mode = 'last_only' ) { if ( ! self::load_libs() ) { throw new RuntimeException( 'PDF libs unavailable.' ); } $signed_docs = array(); $hashes = array(); foreach ( $documents as $doc ) { $source = EquaSign_Storage::read( $doc->source_file ); if ( false === $source ) { throw new RuntimeException( 'Unreadable source document: ' . (int) $doc->id ); } $stroke = isset( $signatures[ $doc->id ] ) ? $signatures[ $doc->id ] : ''; $png = self::render_signature_image( $doc->signature_mode, $stroke ); $bytes = self::affix_on_document( $source, $doc, $png, $sealed_at_utc, $page_mode ); $signed_docs[] = $bytes; $hashes[ (int) $doc->id ] = EquaSign_Crypto::sha256( $bytes ); } return array( 'signed_documents' => $signed_docs, 'hashes' => $hashes, ); } /** * Step 2 — assembles the signed documents into a package and appends the * proof page (which already carries the signed-render fingerprints). * * @param array $signed_docs Signed documents (bytes, ordered). * @param array $proof Full proof bundle. * @return string PDF package (bytes). * @throws RuntimeException If the libs are unavailable. */ public static function assemble_package( array $signed_docs, array $proof ) { if ( ! self::load_libs() ) { throw new RuntimeException( 'PDF libs unavailable.' ); } return self::assemble( $signed_docs, $proof ); } /** * Affixes the signature + notice on a document, returns the signed PDF. * * @param string $source_bytes Source PDF payload. * @param object $doc Document (zone_*). * @param string $signature_png PNG image of the signature. * @param string $sealed_at_utc UTC sealing timestamp. * @return string Signed PDF (bytes). */ private static function affix_on_document( $source_bytes, $doc, $signature_png, $sealed_at_utc, $page_mode = 'last_only' ) { $pdf = new Fpdi( 'P', 'pt' ); $pdf->setPrintHeader( false ); $pdf->setPrintFooter( false ); $pdf->setAutoPageBreak( false ); $pdf->SetMargins( 0, 0, 0 ); $nb = $pdf->setSourceFile( StreamReader::createByString( $source_bytes ) ); // The full signature (type mask) always lands on the LAST page — the fixed // last-page placement. $sig_page = $nb; for ( $p = 1; $p <= $nb; $p++ ) { $tpl = $pdf->importPage( $p ); $size = $pdf->getTemplateSize( $tpl ); $pdf->AddPage( $size['orientation'], array( $size['width'], $size['height'] ) ); $pdf->useTemplate( $tpl ); $pw = (float) $size['width']; $ph = (float) $size['height']; // Common: the full signature is placed on the last page only. $full_here = ( $p === $sig_page ); if ( $full_here ) { // Clamp the mask into this page's box so the signature stays on-page. $w = min( max( 1.0, (float) $doc->zone_width ), $pw ); $h = min( max( 1.0, (float) $doc->zone_height ), $ph ); $x = min( max( 0.0, (float) $doc->zone_x ), $pw - $w ); $y = min( max( 0.0, (float) $doc->zone_y ), $ph - $h ); $y_tcpdf = $ph - ( $y + $h ); // PDF frame (bottom-left) -> TCPDF (top-left). if ( '' !== $signature_png ) { $pdf->Image( '@' . $signature_png, $x, $y_tcpdf, $w, $h, 'PNG', '', '', false, 300, '', false, false, 0 ); } $pdf->SetFont( 'helvetica', '', 7 ); $pdf->SetTextColor( 40, 40, 40 ); $pdf->SetXY( $x, min( $y_tcpdf + $h + 2, $ph - 9 ) ); $pdf->Cell( $w, 9, self::signature_notice( $sealed_at_utc ), 0, 0, 'L' ); } } return $pdf->Output( '', 'S' ); } /** * Assembles the signed documents into a package + appends the proof page. * * @param array $signed_documents List of signed PDFs (bytes). * @param array $proof Proof data. * @return string PDF package (bytes). */ private static function assemble( array $signed_documents, array $proof ) { $pdf = new Fpdi( 'P', 'pt' ); $pdf->setPrintHeader( false ); $pdf->setPrintFooter( false ); $pdf->setAutoPageBreak( false ); $pdf->SetMargins( 0, 0, 0 ); foreach ( $signed_documents as $bytes ) { $nb = $pdf->setSourceFile( StreamReader::createByString( $bytes ) ); for ( $p = 1; $p <= $nb; $p++ ) { $tpl = $pdf->importPage( $p ); $size = $pdf->getTemplateSize( $tpl ); $pdf->AddPage( $size['orientation'], array( $size['width'], $size['height'] ) ); $pdf->useTemplate( $tpl ); } } self::add_proof_page( $pdf, $proof ); return $pdf->Output( '', 'S' ); } /** * Appends the proof page (structured summary) to the package. * * @param Fpdi $pdf Document in progress. * @param array $proof Proof data. * @return void */ private static function add_proof_page( $pdf, array $proof ) { $pdf->setAutoPageBreak( true, 40 ); $pdf->AddPage( 'P', 'A4' ); $pdf->SetMargins( 40, 40, 40 ); $pdf->SetY( 40 ); // Bilingual labels: the proof page must be readable by the operator (admin // language) AND reflect the signer's language (front). $bi() renders a label // in both, "admin / front", collapsing to one when the languages match. Only // LABELS are bilingual; VALUES (name, hashes, timestamps…) stay unique. $bi = self::label_translator( self::locale_for( isset( $proof['front_language'] ) ? (string) $proof['front_language'] : '' ) ); $html = '

' . esc_html( $bi( 'Proof of signature' ) ) . '

'; $html .= '

' . esc_html( $bi( 'This page is part of the sealed package and is frozen at sealing time.' ) ) . '

'; $html .= '
'; $rows = array( $bi( 'Reference' ) => $proof['opaque_reference'], $bi( 'External ref.' ) => $proof['external_reference'], $bi( 'Signer' ) => $proof['signer']['name'], $bi( 'Invitation channel' ) => $proof['channel'], $bi( 'OTP issued' ) => $proof['otp_sent'] ? __( 'yes', 'equasign' ) : __( 'no', 'equasign' ), $bi( 'OTP validated' ) => $proof['otp_validated'] ? __( 'yes', 'equasign' ) : __( 'no', 'equasign' ), /* OTP channel + destination IN CLEAR: an evidential element. */ $bi( 'OTP channel' ) => self::otp_channel_label( $proof ), $bi( 'Sealed at (UTC)' ) => $proof['sealed_at'], $bi( 'IP address' ) => $proof['ip'], ); $html .= ''; foreach ( $rows as $key => $value ) { $html .= ''; } $html .= '
' . esc_html( $key ) . '' . esc_html( (string) $value ) . '
'; $html .= '

' . esc_html( $bi( 'Documents' ) ) . '

'; foreach ( $proof['documents'] as $d ) { $html .= '

' . esc_html( $d['name'] ) . '
'; $html .= esc_html( $bi( 'Source fingerprint (SHA-256):' ) ) . '
' . esc_html( $d['source_sha256'] ) . '
'; if ( ! empty( $d['signed_sha256'] ) ) { $html .= esc_html( $bi( 'Signed fingerprint (SHA-256):' ) ) . '
' . esc_html( $d['signed_sha256'] ) . '
'; } $html .= esc_html( $bi( 'Signature mode' ) ) . ': ' . esc_html( self::mode_label( $d['signature_mode'] ) ) . '
'; if ( ! empty( $d['checkboxes'] ) ) { $html .= esc_html( $bi( 'Checkboxes:' ) ) . '
'; foreach ( $d['checkboxes'] as $c ) { $mark = $c['checked'] ? '[x]' : '[ ]'; $html .= esc_html( $mark . ' ' . $c['label'] ); if ( ! empty( $c['checked_at'] ) ) { $html .= ' (' . esc_html( $c['checked_at'] ) . ')'; } $html .= '
'; } } $html .= '

'; } // Timestamped journal (IP per line): the audit trail up to and including the seal. // The event type is rendered as a human, bilingual label (admin / front) via the // same $bi() mechanism as the rest of the proof page; unknown/future slugs fall // back to a humanized form so a line is never blank. if ( ! empty( $proof['journal'] ) && is_array( $proof['journal'] ) ) { $html .= '

' . esc_html( $bi( 'Event journal (UTC)' ) ) . '

'; $html .= ''; foreach ( $proof['journal'] as $ev ) { $ev_type = (string) ( $ev['type'] ?? '' ); $ev_msgid = self::event_label( $ev_type ); $ev_label = ( '' !== $ev_msgid ) ? $bi( $ev_msgid ) : self::humanize_slug( $ev_type ); $html .= '' . '' . ''; } $html .= '
' . esc_html( (string) ( $ev['at'] ?? '' ) ) . '' . esc_html( $ev_label ) . '' . esc_html( (string) ( $ev['ip'] ?? '' ) ) . '
'; } // Cryptographic seal: the material an external verifier needs to check the // dossier OFF-SERVER (see SIGNATURE.md). Printed in full so the PDF is // self-sufficient. if ( ! empty( $proof['seal'] ) && is_array( $proof['seal'] ) ) { $seal = $proof['seal']; $html .= '

' . esc_html( $bi( 'Cryptographic seal (Ed25519)' ) ) . '

'; $html .= '

' . esc_html( $bi( 'This dossier is signed with the issuer’s Ed25519 key. It can be verified without any server access — see the SIGNATURE.md convention.' ) ) . '

'; $html .= ''; $srows = array( $bi( 'Algorithm' ) => (string) ( $seal['algo'] ?? '' ) . ' (kid ' . (string) ( $seal['kid'] ?? '' ) . ')', $bi( 'Public key (base64)' ) => (string) ( $seal['public_key'] ?? '' ), $bi( 'Signature (base64)' ) => (string) ( $seal['signature'] ?? '' ), $bi( 'Canonical SHA-256' ) => (string) ( $seal['canonical_sha256'] ?? '' ), ); foreach ( $srows as $k => $v ) { $html .= ''; } $html .= '
' . esc_html( $k ) . '' . esc_html( $v ) . '
'; } else { $html .= '

' . esc_html__( 'Note: this dossier is not cryptographically sealed (the master key EQUASIGN_KEK was absent at sealing time).', 'equasign' ) . '

'; } $pdf->writeHTML( $html, true, false, true, false, '' ); } /** * Base language code (e.g. 'fr') → full WordPress locale (e.g. 'fr_FR') for the * native shipped set. Returns '' for the source/English or an unknown code (so * the proof page stays single-language in the admin locale). * * @param string $lang Base language code. * @return string */ private static function locale_for( $lang ) { return equasign_locale_for_lang( $lang ); } /** * Returns a closure $bi($text) that renders a proof-page LABEL in the admin * language and, when the front locale differs, also in the front language * ("admin / front"). Front labels are pre-translated in a single locale switch. * * @param string $front_locale Full locale of the signer's front language, or ''. * @return callable */ private static function label_translator( $front_locale ) { $admin_locale = function_exists( 'get_locale' ) ? get_locale() : ''; $bilingual = ( '' !== $front_locale && $front_locale !== $admin_locale && function_exists( 'switch_to_locale' ) ); $front = array(); if ( $bilingual ) { switch_to_locale( $front_locale ); $front = self::proof_label_map(); restore_previous_locale(); } // $text is always one of the fixed proof-label msgids that proof_label_map() // registers as literal __() calls; resolving the admin translation through that // map (built here in the admin locale) yields the exact same string __( $text ) // returned, while keeping every gettext argument a literal for the i18n scanner. $admin_labels = self::proof_label_map(); return function ( $text ) use ( $front, $bilingual, $admin_labels ) { $admin = isset( $admin_labels[ $text ] ) ? $admin_labels[ $text ] : $text; if ( $bilingual && isset( $front[ $text ] ) && $front[ $text ] !== $admin ) { return $admin . ' / ' . $front[ $text ]; } return $admin; }; } /** * Proof-page labels eligible for bilingual render, mapped from their English * msgid (the lookup key used by $bi()) to the translation in the CURRENTLY * ACTIVE locale. Every __() argument here is a literal so the strings are * collected by any gettext scanner (our extractor and GlotPress alike); the * proof PDF is a legal document, so these must never drop out of the catalog. * * @return array */ private static function proof_label_map() { return array( 'Proof of signature' => __( 'Proof of signature', 'equasign' ), 'This page is part of the sealed package and is frozen at sealing time.' => __( 'This page is part of the sealed package and is frozen at sealing time.', 'equasign' ), 'Reference' => __( 'Reference', 'equasign' ), 'External ref.' => __( 'External ref.', 'equasign' ), 'Signer' => __( 'Signer', 'equasign' ), 'Invitation channel' => __( 'Invitation channel', 'equasign' ), 'OTP issued' => __( 'OTP issued', 'equasign' ), 'OTP validated' => __( 'OTP validated', 'equasign' ), 'OTP channel' => __( 'OTP channel', 'equasign' ), 'Sealed at (UTC)' => __( 'Sealed at (UTC)', 'equasign' ), 'IP address' => __( 'IP address', 'equasign' ), 'Documents' => __( 'Documents', 'equasign' ), 'Source fingerprint (SHA-256):' => __( 'Source fingerprint (SHA-256):', 'equasign' ), 'Signed fingerprint (SHA-256):' => __( 'Signed fingerprint (SHA-256):', 'equasign' ), 'Signature mode' => __( 'Signature mode', 'equasign' ), 'Checkboxes:' => __( 'Checkboxes:', 'equasign' ), 'Event journal (UTC)' => __( 'Event journal (UTC)', 'equasign' ), 'Cryptographic seal (Ed25519)' => __( 'Cryptographic seal (Ed25519)', 'equasign' ), 'This dossier is signed with the issuer’s Ed25519 key. It can be verified without any server access — see the SIGNATURE.md convention.' => __( 'This dossier is signed with the issuer’s Ed25519 key. It can be verified without any server access — see the SIGNATURE.md convention.', 'equasign' ), 'Algorithm' => __( 'Algorithm', 'equasign' ), 'Public key (base64)' => __( 'Public key (base64)', 'equasign' ), 'Signature (base64)' => __( 'Signature (base64)', 'equasign' ), 'Canonical SHA-256' => __( 'Canonical SHA-256', 'equasign' ), // Event-journal labels (bilingual, same rules). The proof journal renders the // event TYPE through these; every label is a literal __() so scanners collect // it and the legal proof never shows a raw machine slug. 'Task created' => __( 'Task created', 'equasign' ), 'Link issued' => __( 'Link issued', 'equasign' ), 'Link renewed' => __( 'Link renewed', 'equasign' ), 'Invitation sent' => __( 'Invitation sent', 'equasign' ), 'Invitation failed' => __( 'Invitation failed', 'equasign' ), 'Document opened' => __( 'Document opened', 'equasign' ), 'Document viewed' => __( 'Document viewed', 'equasign' ), 'Signature code requested' => __( 'Signature code requested', 'equasign' ), 'Signature code sent' => __( 'Signature code sent', 'equasign' ), 'Signature code re-sent' => __( 'Signature code re-sent', 'equasign' ), 'Signature code delivery failed' => __( 'Signature code delivery failed', 'equasign' ), 'Signature code rejected' => __( 'Signature code rejected', 'equasign' ), 'Signature code validated' => __( 'Signature code validated', 'equasign' ), 'Documents rendered and fingerprinted' => __( 'Documents rendered and fingerprinted', 'equasign' ), 'Document signed' => __( 'Document signed', 'equasign' ), 'Dossier sealed' => __( 'Dossier sealed', 'equasign' ), 'Signature declined' => __( 'Signature declined', 'equasign' ), 'Signing attempt blocked' => __( 'Signing attempt blocked', 'equasign' ), 'Task revoked' => __( 'Task revoked', 'equasign' ), 'Task expired' => __( 'Task expired', 'equasign' ), 'Data purged' => __( 'Data purged', 'equasign' ), 'Proof dossier downloaded' => __( 'Proof dossier downloaded', 'equasign' ), 'One-time code sent' => __( 'One-time code sent', 'equasign' ), 'One-time code re-sent' => __( 'One-time code re-sent', 'equasign' ), 'One-time code delivery failed' => __( 'One-time code delivery failed', 'equasign' ), ); } /** * Maps an event-type slug to its English proof label (the $bi() lookup key). Covers * the full validated vocabulary plus LEGACY slugs from dossiers sealed before the * enriched journal (so historical, immutable dossiers still render human labels). * Unknown/future slugs return '' → the caller humanizes the raw slug (never blank). * * @param string $type Event type. * @return string English label msgid, or '' if unknown. */ private static function event_label( $type ) { $map = array( 'created' => 'Task created', 'issued' => 'Link issued', 'renewal' => 'Link renewed', 'invitation_sent' => 'Invitation sent', 'invitation_failed' => 'Invitation failed', 'open' => 'Document opened', 'document_viewed' => 'Document viewed', 'signature_otp_requested' => 'Signature code requested', 'signature_otp_sent' => 'Signature code sent', 'signature_otp_resent' => 'Signature code re-sent', 'signature_otp_send_failed' => 'Signature code delivery failed', 'signature_otp_failed' => 'Signature code rejected', 'signature_otp_verified' => 'Signature code validated', 'documents_hashed' => 'Documents rendered and fingerprinted', 'signed' => 'Document signed', 'sealing' => 'Dossier sealed', 'refused' => 'Signature declined', 'sign_attempt_blocked' => 'Signing attempt blocked', 'revocation' => 'Task revoked', 'expiration' => 'Task expired', 'purge' => 'Data purged', 'proof_downloaded' => 'Proof dossier downloaded', // Legacy slugs (dossiers sealed before the enriched journal — immutable). 'otp_sent' => 'One-time code sent', 'otp_resent' => 'One-time code re-sent', 'otp_send_failed' => 'One-time code delivery failed', ); return isset( $map[ $type ] ) ? $map[ $type ] : ''; } /** * Humanizes an unknown event slug for display (never blank): "some_slug" → "Some slug". * * @param string $slug Event type. * @return string */ private static function humanize_slug( $slug ) { $s = trim( str_replace( '_', ' ', (string) $slug ) ); return '' === $s ? '' : ucfirst( $s ); } /** * Translatable signature-mode label, tolerant of the legacy French values * stored in frozen proof bundles ("clavier" -> keyboard). * * @param string $mode Stored value. * @return string */ public static function mode_label( $mode ) { if ( 'clavier' === $mode ) { $mode = 'keyboard'; } if ( 'canvas' === $mode ) { return __( 'drawn', 'equasign' ); } if ( 'keyboard' === $mode ) { return __( 'typed (handwriting font)', 'equasign' ); } return (string) $mode; } /** * Proof "OTP channel" row: channel + destination IN CLEAR. * * @param array $proof Proof bundle. * @return string */ private static function otp_channel_label( $proof ) { $channel = isset( $proof['otp_channel'] ) ? (string) $proof['otp_channel'] : ''; $destination = isset( $proof['otp_destination'] ) ? (string) $proof['otp_destination'] : ''; if ( '' === $channel ) { return '—'; } $label = __( 'email', 'equasign' ); return $destination ? ( $label . ' — ' . $destination ) : $label; } /** * "Signed on … (UTC)" notice from a UTC timestamp. * * @param string $sealed_at_utc UTC DATETIME. * @return string */ private static function signature_notice( $sealed_at_utc ) { $ts = strtotime( (string) $sealed_at_utc . ' UTC' ); if ( false === $ts ) { $ts = time(); } /* translators: %s: UTC date/time, format dd/mm/yyyy HHhMM. */ return sprintf( __( 'Signed on %s (UTC)', 'equasign' ), gmdate( 'd/m/Y H\hi', $ts ) ); } /* --------------------------------------------------------------------- */ /* Signature render as a PNG image. */ /* --------------------------------------------------------------------- */ /** * Produces the PNG image of the signature according to the mode. * * @param string $mode canvas|keyboard. * @param string $stroke Stroke (PNG dataURL for canvas, text for keyboard). * @return string PNG bytes (empty if nothing to affix). */ public static function render_signature_image( $mode, $stroke ) { if ( 'canvas' === $mode ) { return self::png_from_dataurl( $stroke ); } if ( 'keyboard' === $mode ) { return self::png_from_text( $stroke ); } return ''; } /** * Decodes a PNG image passed as a data URL (canvas mode). * * @param string $dataurl Data URL "data:image/png;base64,...". * @return string PNG bytes, or empty string if invalid. */ private static function png_from_dataurl( $dataurl ) { if ( ! is_string( $dataurl ) || 0 !== strpos( $dataurl, 'data:image/png;base64,' ) ) { return ''; } $b64 = substr( $dataurl, strlen( 'data:image/png;base64,' ) ); $bin = base64_decode( $b64, true ); if ( false === $bin || 0 !== strncmp( $bin, "\x89PNG", 4 ) ) { return ''; } return $bin; } /** * Renders a text (entered name) into PNG with the OFL handwriting font * (keyboard mode). Falls back to the built-in GD font if the TTF is * unavailable. * * @param string $text Entered text. * @return string PNG bytes. */ private static function png_from_text( $text ) { $text = trim( wp_strip_all_tags( (string) $text ) ); if ( '' === $text ) { return ''; } $w = 600; $h = 160; $im = imagecreatetruecolor( $w, $h ); imagesavealpha( $im, true ); $transparent = imagecolorallocatealpha( $im, 255, 255, 255, 127 ); imagefill( $im, 0, 0, $transparent ); $black = imagecolorallocate( $im, 17, 17, 17 ); $font = EQUASIGN_DIR . self::HANDWRITING_FONT; if ( is_file( $font ) && function_exists( 'imagettftext' ) ) { $font_size = 52; $bbox = imagettfbbox( $font_size, 0, $font, $text ); $width = abs( $bbox[2] - $bbox[0] ); while ( $width > ( $w - 40 ) && $font_size > 14 ) { $font_size -= 2; $bbox = imagettfbbox( $font_size, 0, $font, $text ); $width = abs( $bbox[2] - $bbox[0] ); } $x = (int) max( 10, ( $w - $width ) / 2 ); imagettftext( $im, $font_size, 0, $x, 100, $black, $font, $text ); } else { imagestring( $im, 5, 20, 60, $text, $black ); } ob_start(); imagepng( $im ); $png = ob_get_clean(); imagedestroy( $im ); return $png; } }