fingerprint of the signed render. * @return array Proof bundle. */ /** Dossier format version (bumped when the sealed structure changes). */ const FORMAT_VERSION = 2; public static function build( $task, array $documents, array $hashes ) { $docs = array(); foreach ( $documents as $doc ) { $checkboxes = array(); foreach ( EquaSign_Repo::checkboxes_of_document( (int) $doc->id ) as $c ) { $checkboxes[] = array( 'key' => $c->checkbox_key, 'label' => $c->label, 'required' => (bool) $c->required, 'checked' => (bool) $c->checked, 'checked_at' => equasign_iso8601( $c->checked_at ), ); } $docs[] = array( 'id' => (int) $doc->id, 'name' => $doc->name, 'source_sha256' => $doc->source_sha256, 'signed_sha256' => isset( $hashes[ (int) $doc->id ] ) ? $hashes[ (int) $doc->id ] : null, 'signature_mode' => $doc->signature_mode, 'checkboxes' => $checkboxes, ); } // Timestamped journal, one line per recorded event, IP + user-agent per line. // This is the audit trail leading up to (and including) the signature. $journal = array(); foreach ( EquaSign_Repo::events_of_task( (int) $task->id ) as $e ) { $journal[] = array( 'type' => $e->type, 'at' => equasign_iso8601( $e->created_at ), 'ip' => $e->ip, 'user_agent' => $e->user_agent, 'data' => ( null !== $e->data && '' !== $e->data ) ? json_decode( (string) $e->data, true ) : null, ); } // Dossier body (the exact bytes that get Ed25519-sealed). The `seal` block is // added AFTER, over a canonicalization of everything below. $body = array( // 1. Header — self-describing envelope for an off-server reader. 'header' => array( 'product' => 'EquaSign', 'plugin_version' => defined( 'EQUASIGN_VERSION' ) ? EQUASIGN_VERSION : '', 'format_version' => self::FORMAT_VERSION, 'generated_at' => equasign_iso8601( equasign_now_utc() ), ), 'opaque_reference' => $task->opaque_reference, 'external_reference' => $task->external_reference, // Resolved front language of the signer journey — the proof page renders // its labels bilingually (admin + this front language). 'front_language' => EquaSign_Document_Types::resolve_language( isset( $task->document_type ) ? (string) $task->document_type : EquaSign_Document_Types::DEFAULT_TYPE, isset( $task->language ) ? (string) $task->language : '' ), // 4. Identity of the signer + the network coordinates captured at sealing. 'signer' => array( 'name' => $task->signer_name, 'email' => $task->signer_email, 'phone' => isset( $task->signer_phone ) ? $task->signer_phone : null, ), 'channel' => $task->channel, // 5. One-time-code proof (issued/validated + the channel actually used). 'otp_sent' => (bool) $task->otp_sent, 'otp_validated' => true, 'otp_channel' => isset( $task->otp_channel ) ? $task->otp_channel : null, 'otp_destination' => isset( $task->otp_destination ) ? $task->otp_destination : null, 'ip' => $task->signature_ip, 'user_agent' => $task->signature_user_agent, // 7. Signature timestamp (UTC). 'sealed_at' => equasign_iso8601( $task->sealed_at ), // 2 + 3. Embedded documents with their source/signed SHA-256 fingerprints. 'documents' => $docs, // 6. Timestamped journal, IP per line. 'journal' => $journal, ); // 8. Sealing: detached Ed25519 signature over the canonical body. Null when // the KEK is absent (signing is already blocked in that case); the dossier is // still produced, just unsealed. $body['seal'] = EquaSign_Seal::sign( $body ); return $body; } /** * Freezes (encrypts + stores) the proof bundle. Returns the relative path. * * @param array $proof Proof bundle. * @return string|false Path relative to the vault, or false. */ public static function freeze( array $proof ) { return EquaSign_Storage::write( wp_json_encode( $proof ) ); } /** * Reads and decrypts a frozen proof bundle. * * @param string $relative_path Path relative to the vault. * @return array|null */ public static function read( $relative_path ) { $plaintext = EquaSign_Storage::read( $relative_path ); if ( false === $plaintext ) { return null; } $proof = json_decode( $plaintext, true ); return is_array( $proof ) ? $proof : null; } /** * Formats the bundle for the proof API response (contract §4). * * SIGNATURE.md §5 designates the API `proof` response as a VALID off-server verification * input: the seal is a detached Ed25519 signature over the canonicalization of the dossier * body MINUS its `seal` block. The response must therefore be the EXACT signed body — every * sealed component verbatim (opaque_reference, external_reference, each document's * id / source_sha256 / signed_sha256 / signature_mode, the journal) — or the seal no longer * verifies from it (regression A-3: a prior version dropped opaque/external_reference and * documents[].id/.signed_sha256 while keeping the seal verbatim, so the served body diverged * from the signed one and the §5 reference implementation failed to verify). * * We therefore return the frozen bundle AS-IS. Nothing is dropped or normalised on this * verification path — in particular the legacy "clavier" signature_mode stays verbatim (the * seal anchors that exact byte; the display layers — PDF reader and proof page — map * "clavier" -> "keyboard" at render time, never here). The bundle carries proof data only * (no secret, no key material), so serving it verbatim is safe. * * @param array $proof Frozen proof bundle (the exact body the seal was computed over, + seal). * @return array The signed body verbatim, verifiable per SIGNATURE.md §5. */ public static function for_api( array $proof ) { return $proof; } }