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 .= '| ' . esc_html( $key ) . ' | ' . esc_html( (string) $value ) . ' |
';
}
$html .= '
';
$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 .= '| ' . esc_html( (string) ( $ev['at'] ?? '' ) ) . ' | '
. '' . esc_html( $ev_label ) . ' | '
. '' . esc_html( (string) ( $ev['ip'] ?? '' ) ) . ' |
';
}
$html .= '
';
}
// 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 .= '| ' . esc_html( $k ) . ' | ' . esc_html( $v ) . ' |
';
}
$html .= '
';
} 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;
}
}