diff --git a/cmd/ots/pdf.go b/cmd/ots/pdf.go index bccfe12..d05b9c7 100644 --- a/cmd/ots/pdf.go +++ b/cmd/ots/pdf.go @@ -1,20 +1,11 @@ package main import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "fmt" "log/slog" "os" - "path/filepath" - "strings" - "time" "git.intruders.space/public/opentimestamps" - "git.intruders.space/public/opentimestamps/ots" - "git.intruders.space/public/opentimestamps/verifyer" - "github.com/jung-kurt/gofpdf" + "git.intruders.space/public/opentimestamps/pdf" "github.com/spf13/cobra" ) @@ -64,10 +55,6 @@ func runPdfCmd(cmd *cobra.Command, args []string) { os.Exit(1) } - // Compute the file digest - fileDigest := sha256.Sum256(fileData) - slog.Debug("Computed file digest", "digest", hex.EncodeToString(fileDigest[:])) - // Read and parse the OTS file otsData, err := os.ReadFile(otsPath) if err != nil { @@ -81,294 +68,32 @@ func runPdfCmd(cmd *cobra.Command, args []string) { os.Exit(1) } - // Check if the digests match - if !bytes.Equal(fileDigest[:], timestampFile.Digest) { - slog.Warn("Warning: File digest doesn't match timestamp digest", - "file_digest", hex.EncodeToString(fileDigest[:]), - "timestamp_digest", hex.EncodeToString(timestampFile.Digest)) + // Set up PDF options from command line flags + options := pdf.CertificateOptions{ + Title: pdfTitle, + Comment: pdfComment, + IncludeContent: pdfIncludeContent, + EsploraURL: pdfEsploraURL, } - // Extract Bitcoin attestations - bitcoinSeqs := timestampFile.GetBitcoinAttestedSequences() - if len(bitcoinSeqs) == 0 { - slog.Warn("No Bitcoin attestations found in timestamp, certificate will be for pending timestamps") - } - - // Create Bitcoin interface for block time lookup - bitcoin := verifyer.NewEsploraClient(pdfEsploraURL, 30*time.Second) - slog.Info("Using Esplora API for block information", "url", pdfEsploraURL) - // Generate the PDF - err = generateVerificationPDF( - outputPath, + pdfBuffer, err := pdf.GenerateCertificate( filePath, fileData, - fileDigest[:], timestampFile, - bitcoinSeqs, - bitcoin, + options, ) if err != nil { slog.Error("Failed to generate PDF", "error", err) os.Exit(1) } + // Write to file + err = pdf.WriteToFile(pdfBuffer, outputPath) + if err != nil { + slog.Error("Failed to write PDF to file", "file", outputPath, "error", err) + os.Exit(1) + } + slog.Info("PDF certificate generated successfully", "file", outputPath) } - -func generateVerificationPDF(outputPath, filePath string, fileData []byte, fileDigest []byte, - timestamp *ots.File, bitcoinSeqs []ots.Sequence, bitcoin verifyer.Bitcoin) error { - - // Create a new PDF - pdf := gofpdf.New("P", "mm", "A4", "") - pdf.SetTitle(pdfTitle, true) - pdf.SetAuthor("OpenTimestamps", true) - pdf.SetCreator("OpenTimestamps CLI", true) - - // Set margins - pdf.SetMargins(25, 25, 25) - pdf.SetAutoPageBreak(true, 25) - - // Add first page - Certificate - pdf.AddPage() - - // Title - pdf.SetFont("Helvetica", "B", 24) - pdf.CellFormat(0, 12, pdfTitle, "", 1, "C", false, 0, "") - pdf.Ln(10) - - // Timestamp information section - pdf.SetFont("Helvetica", "B", 18) - pdf.CellFormat(0, 10, "Timestamp Information", "", 1, "L", false, 0, "") - pdf.Ln(5) - - pdf.SetFont("Helvetica", "B", 12) - pdf.CellFormat(0, 8, "Filename:", "", 1, "L", false, 0, "") - pdf.SetFont("Helvetica", "", 12) - pdf.CellFormat(0, 8, filepath.Base(filePath), "", 1, "L", false, 0, "") - pdf.Ln(2) - - // Timestamp date - pdf.SetFont("Helvetica", "B", 12) - if len(bitcoinSeqs) > 0 { - // Find the earliest Bitcoin attestation - var earliestHeight uint64 - var earliestBlockTime time.Time - - for _, seq := range bitcoinSeqs { - att := seq.GetAttestation() - if earliestHeight == 0 || att.BitcoinBlockHeight < earliestHeight { - earliestHeight = att.BitcoinBlockHeight - - // Fetch the block time from the blockchain - blockHash, err := bitcoin.GetBlockHash(int64(att.BitcoinBlockHeight)) - if err == nil { - blockHeader, err := bitcoin.GetBlockHeader(blockHash) - if err == nil { - earliestBlockTime = time.Unix(int64(blockHeader.Timestamp.Unix()), 0) - } - } - } - } - - // Show Bitcoin timestamp - pdf.CellFormat(0, 8, "Timestamp Created:", "", 1, "L", false, 0, "") - pdf.SetFont("Helvetica", "", 12) - - if !earliestBlockTime.IsZero() { - // If we successfully retrieved the block time, display it - pdf.CellFormat(0, 8, fmt.Sprintf("Bitcoin Block #%d (%s)", - earliestHeight, earliestBlockTime.Format("Jan 02, 2006 15:04:05 UTC")), - "", 1, "L", false, 0, "") - } else { - // Fallback to just showing the block height - pdf.CellFormat(0, 8, fmt.Sprintf("Bitcoin Block #%d", earliestHeight), "", 1, "L", false, 0, "") - } - } else { - // Pending attestation - pdf.CellFormat(0, 8, "Status:", "", 1, "L", false, 0, "") - pdf.SetFont("Helvetica", "", 12) - pdf.CellFormat(0, 8, "Pending confirmation in the Bitcoin blockchain", "", 1, "L", false, 0, "") - } - pdf.Ln(2) - - // File hash - pdf.SetFont("Helvetica", "B", 12) - pdf.CellFormat(0, 8, "File SHA-256 Digest:", "", 1, "L", false, 0, "") - pdf.SetFont("Courier", "", 10) - pdf.CellFormat(0, 8, hex.EncodeToString(fileDigest), "", 1, "L", false, 0, "") - pdf.Ln(5) - - // Include comment if provided - if pdfComment != "" { - pdf.SetFont("Helvetica", "B", 12) - pdf.CellFormat(0, 8, "Comment:", "", 1, "L", false, 0, "") - pdf.SetFont("Helvetica", "", 12) - pdf.MultiCell(0, 8, pdfComment, "", "L", false) - pdf.Ln(5) - } - - // Include file content if requested and looks like text - if pdfIncludeContent && isTextFile(fileData) && len(fileData) < 5000 { - pdf.SetFont("Helvetica", "B", 12) - pdf.CellFormat(0, 8, "File Content:", "", 1, "L", false, 0, "") - pdf.SetFont("Courier", "", 10) - content := string(fileData) - content = strings.ReplaceAll(content, "\r\n", "\n") - pdf.MultiCell(0, 5, content, "", "L", false) - pdf.Ln(5) - } - - // Add Bitcoin transaction information for each attestation - if len(bitcoinSeqs) > 0 { - pdf.SetFont("Helvetica", "B", 18) - pdf.CellFormat(0, 10, "Bitcoin Attestations", "", 1, "L", false, 0, "") - pdf.Ln(5) - - for i, seq := range bitcoinSeqs { - att := seq.GetAttestation() - - pdf.SetFont("Helvetica", "B", 14) - pdf.CellFormat(0, 8, fmt.Sprintf("Attestation #%d", i+1), "", 1, "L", false, 0, "") - pdf.Ln(2) - - // Get block details - blockHash, err := bitcoin.GetBlockHash(int64(att.BitcoinBlockHeight)) - blockTime := "Unknown" - if err == nil { - blockHeader, err := bitcoin.GetBlockHeader(blockHash) - if err == nil { - blockTime = blockHeader.Timestamp.Format("Jan 02, 2006 15:04:05 UTC") - } - } - - pdf.SetFont("Helvetica", "B", 12) - pdf.CellFormat(0, 8, "Bitcoin Block Height:", "", 1, "L", false, 0, "") - pdf.SetFont("Helvetica", "", 12) - pdf.CellFormat(0, 8, fmt.Sprintf("%d", att.BitcoinBlockHeight), "", 1, "L", false, 0, "") - - pdf.SetFont("Helvetica", "B", 12) - pdf.CellFormat(0, 8, "Block Time:", "", 1, "L", false, 0, "") - pdf.SetFont("Helvetica", "", 12) - pdf.CellFormat(0, 8, blockTime, "", 1, "L", false, 0, "") - - pdf.SetFont("Helvetica", "B", 12) - pdf.CellFormat(0, 8, "Block Hash:", "", 1, "L", false, 0, "") - pdf.SetFont("Courier", "", 10) - if blockHash != nil { - pdf.CellFormat(0, 8, blockHash.String(), "", 1, "L", false, 0, "") - } else { - pdf.CellFormat(0, 8, "Unable to retrieve", "", 1, "L", false, 0, "") - } - - // In a full implementation, we would show the Bitcoin merkle root and transaction ID - // This would require additional API calls to get that information - - // Show operations sequence - pdf.SetFont("Helvetica", "B", 12) - pdf.CellFormat(0, 8, "Verification Operations:", "", 1, "L", false, 0, "") - pdf.SetFont("Courier", "", 10) - - // Format the operations - var operationsText string - merkleRoot, _ := seq.Compute(timestamp.Digest) - - operationsText += fmt.Sprintf("Starting with digest: %s\n", hex.EncodeToString(timestamp.Digest)) - - for j, inst := range seq { - if inst.Operation != nil { - if inst.Operation.Binary { - operationsText += fmt.Sprintf("%d. %s %s\n", j+1, inst.Operation.Name, hex.EncodeToString(inst.Argument)) - } else { - operationsText += fmt.Sprintf("%d. %s\n", j+1, inst.Operation.Name) - } - } - } - - operationsText += fmt.Sprintf("\nResulting merkle root: %s\n", hex.EncodeToString(merkleRoot)) - - pdf.MultiCell(0, 5, operationsText, "", "L", false) - pdf.Ln(5) - } - } - - // Add second page - Verification Instructions - pdf.AddPage() - pdf.SetFont("Helvetica", "B", 18) - pdf.CellFormat(0, 10, "Verification Instructions", "", 1, "L", false, 0, "") - pdf.Ln(5) - - // Instructions - addVerificationInstructions(pdf) - - // Save the PDF - return pdf.OutputFileAndClose(outputPath) -} - -func addVerificationInstructions(pdf *gofpdf.Fpdf) { - pdf.SetFont("Helvetica", "B", 14) - pdf.CellFormat(0, 8, "1. Compute the SHA-256 digest of your file", "", 1, "L", false, 0, "") - pdf.SetFont("Helvetica", "", 12) - pdf.MultiCell(0, 6, `To verify this timestamp, first compute the SHA-256 digest of your original file. You can use various tools for this: - -- On Linux/macOS: Use the command "shasum -a 256 filename" -- On Windows: Use tools like PowerShell's "Get-FileHash" or third-party tools -- Online: Various websites can calculate file hashes, but only use trusted sources for sensitive files - -Verify that the digest matches the one shown in this certificate.`, "", "L", false) - pdf.Ln(5) - - pdf.SetFont("Helvetica", "B", 14) - pdf.CellFormat(0, 8, "2. Verify the operations sequence", "", 1, "L", false, 0, "") - pdf.SetFont("Helvetica", "", 12) - pdf.MultiCell(0, 6, `The operations sequence shown in this certificate transforms the file digest into a value that was stored in the Bitcoin blockchain. To verify manually: - -- Start with the file's SHA-256 digest -- Apply each operation in sequence as shown in the certificate -- For "append" operations, add the shown bytes to the end of the current value -- For "prepend" operations, add the shown bytes to the beginning of the current value -- For "sha256" operations, compute the SHA-256 hash of the current value -- The final result should match the merkle root shown in the certificate`, "", "L", false) - pdf.Ln(5) - - pdf.SetFont("Helvetica", "B", 14) - pdf.CellFormat(0, 8, "3. Verify the Bitcoin attestation", "", 1, "L", false, 0, "") - pdf.SetFont("Helvetica", "", 12) - pdf.MultiCell(0, 6, `For full verification, check that the merkle root was recorded in the Bitcoin blockchain: - -- Find the Bitcoin block at the height shown in the certificate -- The block's merkle root or transaction should contain the computed value -- You can use block explorers like blockstream.info or blockchain.com -- The timestamp is confirmed when the merkle root appears in the blockchain`, "", "L", false) - pdf.Ln(5) - - pdf.SetFont("Helvetica", "B", 14) - pdf.CellFormat(0, 8, "4. Use the OpenTimestamps verifier", "", 1, "L", false, 0, "") - pdf.SetFont("Helvetica", "", 12) - pdf.MultiCell(0, 6, `For easier verification, use the OpenTimestamps verification tools: - -- Command line: Use "ots verify " -- Online: Visit https://opentimestamps.org and upload your file and .ots timestamp -- The tools will perform all verification steps automatically`, "", "L", false) - - // Add footer with generation time - pdf.Ln(10) - pdf.SetFont("Helvetica", "I", 10) - pdf.CellFormat(0, 6, fmt.Sprintf("Certificate generated at: %s", time.Now().Format(time.RFC3339)), "", 1, "C", false, 0, "") - pdf.CellFormat(0, 6, "OpenTimestamps - https://opentimestamps.org", "", 1, "C", false, 0, "") -} - -// isTextFile tries to determine if a file is likely to be text -func isTextFile(data []byte) bool { - // Simple heuristic: if the file doesn't contain too many non-printable characters, - // it's probably text - nonPrintable := 0 - for _, b := range data { - if (b < 32 || b > 126) && b != 9 && b != 10 && b != 13 { - nonPrintable++ - } - } - - // Allow up to 1% non-printable characters - return nonPrintable <= len(data)/100 -} diff --git a/fixtures/flatearthers-united.txt.certificate.pdf b/fixtures/flatearthers-united.txt.certificate.pdf index a576ddd..b056658 100644 Binary files a/fixtures/flatearthers-united.txt.certificate.pdf and b/fixtures/flatearthers-united.txt.certificate.pdf differ diff --git a/pdf/certificate.go b/pdf/certificate.go new file mode 100644 index 0000000..2c68605 --- /dev/null +++ b/pdf/certificate.go @@ -0,0 +1,343 @@ +package pdf + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "git.intruders.space/public/opentimestamps/ots" + "git.intruders.space/public/opentimestamps/verifyer" + "github.com/jung-kurt/gofpdf" +) + +// CertificateOptions contains options for generating a PDF certificate +type CertificateOptions struct { + // Title for the PDF document + Title string + // Additional comment to include in the certificate + Comment string + // Whether to include the original file content in the PDF (for text files only) + IncludeContent bool + // URL of Esplora API for fetching block information + EsploraURL string +} + +// DefaultOptions returns the default certificate options +func DefaultOptions() CertificateOptions { + return CertificateOptions{ + Title: "OpenTimestamps Certificate", + Comment: "", + IncludeContent: false, + EsploraURL: "https://blockstream.info/api", + } +} + +// GenerateCertificate generates a PDF certificate for an OpenTimestamps timestamp +// It returns a bytes.Buffer containing the PDF data +func GenerateCertificate( + filename string, + fileData []byte, + timestampFile *ots.File, + options CertificateOptions, +) (*bytes.Buffer, error) { + // Compute the file digest + fileDigest := sha256.Sum256(fileData) + + // Extract Bitcoin attestations + bitcoinSeqs := timestampFile.GetBitcoinAttestedSequences() + + // Create Bitcoin interface for block time lookup + bitcoin := verifyer.NewEsploraClient(options.EsploraURL, 30*time.Second) + + // Create a buffer to store the PDF + var buf bytes.Buffer + + // Generate the PDF into the buffer + err := generateVerificationPDF( + &buf, + filename, + fileData, + fileDigest[:], + timestampFile, + bitcoinSeqs, + bitcoin, + options, + ) + if err != nil { + return nil, err + } + + return &buf, nil +} + +// WriteToFile writes the certificate buffer to a file +func WriteToFile(buf *bytes.Buffer, outputPath string) error { + return os.WriteFile(outputPath, buf.Bytes(), 0644) +} + +func generateVerificationPDF( + output io.Writer, + filePath string, + fileData []byte, + fileDigest []byte, + timestamp *ots.File, + bitcoinSeqs []ots.Sequence, + bitcoin verifyer.Bitcoin, + options CertificateOptions, +) error { + // Create a new PDF + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.SetTitle(options.Title, true) + pdf.SetAuthor("OpenTimestamps", true) + pdf.SetCreator("OpenTimestamps CLI", true) + + // Set margins + pdf.SetMargins(25, 25, 25) + pdf.SetAutoPageBreak(true, 25) + + // Add first page - Certificate + pdf.AddPage() + + // Title + pdf.SetFont("Helvetica", "B", 24) + pdf.CellFormat(0, 12, options.Title, "", 1, "C", false, 0, "") + pdf.Ln(10) + + // Timestamp information section + pdf.SetFont("Helvetica", "B", 18) + pdf.CellFormat(0, 10, "Timestamp Information", "", 1, "L", false, 0, "") + pdf.Ln(5) + + pdf.SetFont("Helvetica", "B", 12) + pdf.CellFormat(0, 8, "Filename:", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 12) + pdf.CellFormat(0, 8, filepath.Base(filePath), "", 1, "L", false, 0, "") + pdf.Ln(2) + + // Timestamp date + pdf.SetFont("Helvetica", "B", 12) + if len(bitcoinSeqs) > 0 { + // Find the earliest Bitcoin attestation + var earliestHeight uint64 + var earliestBlockTime time.Time + + for _, seq := range bitcoinSeqs { + att := seq.GetAttestation() + if earliestHeight == 0 || att.BitcoinBlockHeight < earliestHeight { + earliestHeight = att.BitcoinBlockHeight + + // Fetch the block time from the blockchain + blockHash, err := bitcoin.GetBlockHash(int64(att.BitcoinBlockHeight)) + if err == nil { + blockHeader, err := bitcoin.GetBlockHeader(blockHash) + if err == nil { + earliestBlockTime = time.Unix(int64(blockHeader.Timestamp.Unix()), 0) + } + } + } + } + + // Show Bitcoin timestamp + pdf.CellFormat(0, 8, "Timestamp Created:", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 12) + + if !earliestBlockTime.IsZero() { + // If we successfully retrieved the block time, display it + pdf.CellFormat(0, 8, fmt.Sprintf("Bitcoin Block #%d (%s)", + earliestHeight, earliestBlockTime.Format("Jan 02, 2006 15:04:05 UTC")), + "", 1, "L", false, 0, "") + } else { + // Fallback to just showing the block height + pdf.CellFormat(0, 8, fmt.Sprintf("Bitcoin Block #%d", earliestHeight), "", 1, "L", false, 0, "") + } + } else { + // Pending attestation + pdf.CellFormat(0, 8, "Status:", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 12) + pdf.CellFormat(0, 8, "Pending confirmation in the Bitcoin blockchain", "", 1, "L", false, 0, "") + } + pdf.Ln(2) + + // File hash + pdf.SetFont("Helvetica", "B", 12) + pdf.CellFormat(0, 8, "File SHA-256 Digest:", "", 1, "L", false, 0, "") + pdf.SetFont("Courier", "", 10) + pdf.CellFormat(0, 8, hex.EncodeToString(fileDigest), "", 1, "L", false, 0, "") + pdf.Ln(5) + + // Include comment if provided + if options.Comment != "" { + pdf.SetFont("Helvetica", "B", 12) + pdf.CellFormat(0, 8, "Comment:", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 12) + pdf.MultiCell(0, 8, options.Comment, "", "L", false) + pdf.Ln(5) + } + + // Include file content if requested and looks like text + if options.IncludeContent && isTextFile(fileData) && len(fileData) < 5000 { + pdf.SetFont("Helvetica", "B", 12) + pdf.CellFormat(0, 8, "File Content:", "", 1, "L", false, 0, "") + pdf.SetFont("Courier", "", 10) + content := string(fileData) + content = strings.ReplaceAll(content, "\r\n", "\n") + pdf.MultiCell(0, 5, content, "", "L", false) + pdf.Ln(5) + } + + // Add Bitcoin transaction information for each attestation + if len(bitcoinSeqs) > 0 { + pdf.SetFont("Helvetica", "B", 18) + pdf.CellFormat(0, 10, "Bitcoin Attestations", "", 1, "L", false, 0, "") + pdf.Ln(5) + + for i, seq := range bitcoinSeqs { + att := seq.GetAttestation() + + pdf.SetFont("Helvetica", "B", 14) + pdf.CellFormat(0, 8, fmt.Sprintf("Attestation #%d", i+1), "", 1, "L", false, 0, "") + pdf.Ln(2) + + // Get block details + blockHash, err := bitcoin.GetBlockHash(int64(att.BitcoinBlockHeight)) + blockTime := "Unknown" + if err == nil { + blockHeader, err := bitcoin.GetBlockHeader(blockHash) + if err == nil { + blockTime = blockHeader.Timestamp.Format("Jan 02, 2006 15:04:05 UTC") + } + } + + pdf.SetFont("Helvetica", "B", 12) + pdf.CellFormat(0, 8, "Bitcoin Block Height:", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 12) + pdf.CellFormat(0, 8, fmt.Sprintf("%d", att.BitcoinBlockHeight), "", 1, "L", false, 0, "") + + pdf.SetFont("Helvetica", "B", 12) + pdf.CellFormat(0, 8, "Block Time:", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 12) + pdf.CellFormat(0, 8, blockTime, "", 1, "L", false, 0, "") + + pdf.SetFont("Helvetica", "B", 12) + pdf.CellFormat(0, 8, "Block Hash:", "", 1, "L", false, 0, "") + pdf.SetFont("Courier", "", 10) + if blockHash != nil { + pdf.CellFormat(0, 8, blockHash.String(), "", 1, "L", false, 0, "") + } else { + pdf.CellFormat(0, 8, "Unable to retrieve", "", 1, "L", false, 0, "") + } + + // Show operations sequence + pdf.SetFont("Helvetica", "B", 12) + pdf.CellFormat(0, 8, "Verification Operations:", "", 1, "L", false, 0, "") + pdf.SetFont("Courier", "", 10) + + // Format the operations + var operationsText string + merkleRoot, _ := seq.Compute(timestamp.Digest) + + operationsText += fmt.Sprintf("Starting with digest: %s\n", hex.EncodeToString(timestamp.Digest)) + + for j, inst := range seq { + if inst.Operation != nil { + if inst.Operation.Binary { + operationsText += fmt.Sprintf("%d. %s %s\n", j+1, inst.Operation.Name, hex.EncodeToString(inst.Argument)) + } else { + operationsText += fmt.Sprintf("%d. %s\n", j+1, inst.Operation.Name) + } + } + } + + operationsText += fmt.Sprintf("\nResulting merkle root: %s\n", hex.EncodeToString(merkleRoot)) + + pdf.MultiCell(0, 5, operationsText, "", "L", false) + pdf.Ln(5) + } + } + + // Add second page - Verification Instructions + pdf.AddPage() + pdf.SetFont("Helvetica", "B", 18) + pdf.CellFormat(0, 10, "Verification Instructions", "", 1, "L", false, 0, "") + pdf.Ln(5) + + // Instructions + addVerificationInstructions(pdf) + + // Save the PDF to the writer + return pdf.Output(output) +} + +func addVerificationInstructions(pdf *gofpdf.Fpdf) { + pdf.SetFont("Helvetica", "B", 14) + pdf.CellFormat(0, 8, "1. Compute the SHA-256 digest of your file", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 12) + pdf.MultiCell(0, 6, `To verify this timestamp, first compute the SHA-256 digest of your original file. You can use various tools for this: + +- On Linux/macOS: Use the command "shasum -a 256 filename" +- On Windows: Use tools like PowerShell's "Get-FileHash" or third-party tools +- Online: Various websites can calculate file hashes, but only use trusted sources for sensitive files + +Verify that the digest matches the one shown in this certificate.`, "", "L", false) + pdf.Ln(5) + + pdf.SetFont("Helvetica", "B", 14) + pdf.CellFormat(0, 8, "2. Verify the operations sequence", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 12) + pdf.MultiCell(0, 6, `The operations sequence shown in this certificate transforms the file digest into a value that was stored in the Bitcoin blockchain. To verify manually: + +- Start with the file's SHA-256 digest +- Apply each operation in sequence as shown in the certificate +- For "append" operations, add the shown bytes to the end of the current value +- For "prepend" operations, add the shown bytes to the beginning of the current value +- For "sha256" operations, compute the SHA-256 hash of the current value +- The final result should match the merkle root shown in the certificate`, "", "L", false) + pdf.Ln(5) + + pdf.SetFont("Helvetica", "B", 14) + pdf.CellFormat(0, 8, "3. Verify the Bitcoin attestation", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 12) + pdf.MultiCell(0, 6, `For full verification, check that the merkle root was recorded in the Bitcoin blockchain: + +- Find the Bitcoin block at the height shown in the certificate +- The block's merkle root or transaction should contain the computed value +- You can use block explorers like blockstream.info or blockchain.com +- The timestamp is confirmed when the merkle root appears in the blockchain`, "", "L", false) + pdf.Ln(5) + + pdf.SetFont("Helvetica", "B", 14) + pdf.CellFormat(0, 8, "4. Use the OpenTimestamps verifier", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 12) + pdf.MultiCell(0, 6, `For easier verification, use the OpenTimestamps verification tools: + +- Command line: Use "ots verify " +- Online: Visit https://opentimestamps.org and upload your file and .ots timestamp +- The tools will perform all verification steps automatically`, "", "L", false) + + // Add footer with generation time + pdf.Ln(10) + pdf.SetFont("Helvetica", "I", 10) + pdf.CellFormat(0, 6, fmt.Sprintf("Certificate generated at: %s", time.Now().Format(time.RFC3339)), "", 1, "C", false, 0, "") + pdf.CellFormat(0, 6, "OpenTimestamps - https://opentimestamps.org", "", 1, "C", false, 0, "") +} + +// isTextFile tries to determine if a file is likely to be text +func isTextFile(data []byte) bool { + // Simple heuristic: if the file doesn't contain too many non-printable characters, + // it's probably text + nonPrintable := 0 + for _, b := range data { + if (b < 32 || b > 126) && b != 9 && b != 10 && b != 13 { + nonPrintable++ + } + } + + // Allow up to 1% non-printable characters + return nonPrintable <= len(data)/100 +}