3 Commits

Author SHA1 Message Date
01a46df1f0 move pdf generation code from command to separate package 2025-04-11 22:38:48 +02:00
7d577f383e add pdf command, show block time in verify command 2025-04-11 13:57:38 +02:00
03f30f1968 refactor 2025-04-11 13:31:02 +02:00
51 changed files with 2153 additions and 474 deletions

203
README.md
View File

@@ -1,80 +1,175 @@
# opentimestamps
Interact with calendar servers, create and verify OTS attestations.
A fork of [github.com/nbd-wtf/opentimestamps](https://github.com/nbd-wtf/opentimestamps) that lets you interact with calendar servers, create and verify OTS attestations.
# How to use
Full documentation at https://pkg.go.dev/github.com/nbd-wtf/opentimestamps. See some commented pseudocode below (you probably should not try to run it as it is).
Here's an example of how to use the library to create a timestamp, attempt to upgrade it periodically, and display information about it:
```go
package main
import "github.com/nbd-wtf/opentimestamps"
import (
"context"
"crypto/sha256"
"fmt"
"os"
"time"
func main () {
// create a timestamp at a specific calendar server
hash := sha256.Sum256([]byte{1,2,3,4,5,6})
seq, _ := opentimestamps.Stamp(context.Background(), "https://alice.btc.calendar.opentimestamps.org/", hash)
"git.intruders.space/public/opentimestamps"
"git.intruders.space/public/opentimestamps/ots"
)
// you can just call UpgradeSequence() to get the upgraded sequence (or an error if not yet available)
upgradedSeq, err := opentimestamps.UpgradeSequence(context.Background(), seq, hash[:])
if err != nil {
fmt.Println("wait more")
}
func main() {
// Read a file to timestamp
fileData, err := os.ReadFile("document.txt")
if err != nil {
fmt.Println("Error reading file:", err)
return
}
// a File is a struct that represents the content of an .ots file, which contains the initial digest and any number of sequences
file := File{
Digest: hash,
Sequences: []Sequence{seq},
}
// Calculate the digest
digest := sha256.Sum256(fileData)
fmt.Printf("File digest: %x\n", digest)
// it can be written to disk
os.WriteFile("file.ots", file.SerializeToFile(), 0644)
// Define calendar servers
calendars := []string{
"https://alice.btc.calendar.opentimestamps.org",
"https://bob.btc.calendar.opentimestamps.org",
"https://finney.calendar.eternitywall.com",
}
// or printed in human-readable format
fmt.Println(file.Human())
// Create a timestamp using each calendar
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// sequences are always composed of a bunch of operation instructions -- these can be, for example, "append", "prepend", "sha256"
fmt.Println(seq[0].Operation.Name) // "append"
fmt.Println(seq[1].Operation.Name) // "sha256"
fmt.Println(seq[2].Operation.Name) // "prepend"
var sequences []ots.Sequence
for _, calendarURL := range calendars {
fmt.Printf("Submitting to %s...\n", calendarURL)
seq, err := opentimestamps.Stamp(ctx, calendarURL, digest)
if err != nil {
fmt.Printf("Failed to submit to %s: %v\n", calendarURL, err)
continue
}
fmt.Printf("Submission to %s successful\n", calendarURL)
sequences = append(sequences, seq)
}
// "prepend" and "append" are "binary", i.e. they take an argument
fmt.Println(hex.EncodeToString(seq[2].Argument)) // "c40fe258f9b828a0b5a7"
if len(sequences) == 0 {
fmt.Println("Failed to create any timestamps")
return
}
// all these instructions can be executed in order, starting from the initial hash
result := seq.Compute(hash) // this is the value we send to the calendar server in order to get the upgraded sequence
finalResult := upgradedSeq.Compute(hash) // this should be the merkle root of a bitcoin block if this sequence is upgraded
// Create the timestamp file
file := &ots.File{
Digest: digest[:],
Sequences: sequences,
}
// each sequence always ends in an "attestation"
// it can be either a pending attestation, i.e. a reference to a calendar server from which we will upgrade this sequence later
fmt.Println(seq[len(seq)-1].Attestation.CalendarServerURL) // "https://alice.btc.calendar.opentimestamps.org/"
// or it can be a reference to a bitcoin block, the merkle root of which we will check against the result of Compute() for verifying
fmt.Println(upgradedSeq[len(upgradedSeq)-1].Attestation.BitcoinBlockHeight) // 810041
// Save the OTS file
otsData := file.SerializeToFile()
if err := os.WriteFile("document.txt.ots", otsData, 0644); err != nil {
fmt.Println("Failed to save OTS file:", err)
return
}
fmt.Println("Timestamp file created successfully")
// speaking of verifying, this is how we do it:
// first we need some source of bitcoin blocks,
var bitcoin opentimestamps.Bitcoin
if useLocallyRunningBitcoindNode {
// it can be either a locally running bitcoind node
bitcoin, _ = opentimestamps.NewBitcoindInterface(rpcclient.ConnConfig{
User: "nakamoto",
Pass: "mumbojumbo",
HTTPPostMode: true,
})
} else {
// or an esplora HTTP endpoint
bitcoin = opentimestamps.NewEsploraClient("https://blockstream.info/api")
}
// Display initial timestamp info
fmt.Println("\nInitial timestamp info:")
fmt.Println(file.Human(false))
// then we pass that to a sequence
if err := upgradedSeq.Verify(bitcoin, hash); err == nil {
fmt.Println("it works!")
}
// Attempt to upgrade the timestamp every 20 minutes
fmt.Println("\nWill check for upgrades every 20 minutes...")
maxAttempts := 12 // Try for about 4 hours (12 * 20 minutes)
for attempt := 0; attempt < maxAttempts; attempt++ {
if attempt > 0 {
fmt.Printf("\nWaiting 20 minutes before next upgrade attempt (%d/%d)...\n", attempt+1, maxAttempts)
time.Sleep(20 * time.Minute)
}
upgraded := false
pendingSequences := file.GetPendingSequences()
if len(pendingSequences) == 0 {
fmt.Println("No pending sequences to upgrade")
break
}
fmt.Printf("Attempting to upgrade %d pending sequences...\n", len(pendingSequences))
upgradeCtx, upgradeCancel := context.WithTimeout(context.Background(), 30*time.Second)
for _, seq := range pendingSequences {
att := seq.GetAttestation()
fmt.Printf("Trying to upgrade sequence from %s...\n", att.CalendarServerURL)
upgradedSeq, err := opentimestamps.UpgradeSequence(upgradeCtx, seq, digest[:])
if err != nil {
fmt.Printf("Failed to upgrade sequence from %s: %v\n", att.CalendarServerURL, err)
continue
}
// Replace the sequence in the file
for i, origSeq := range file.Sequences {
origAtt := origSeq.GetAttestation()
if origAtt.CalendarServerURL == att.CalendarServerURL {
file.Sequences[i] = upgradedSeq
upgraded = true
newAtt := upgradedSeq.GetAttestation()
if newAtt.BitcoinBlockHeight > 0 {
fmt.Printf("Sequence upgraded! Confirmed in Bitcoin block %d\n", newAtt.BitcoinBlockHeight)
} else {
fmt.Println("Sequence updated but still pending")
}
break
}
}
}
upgradeCancel()
if upgraded {
// Save the upgraded file
otsData = file.SerializeToFile()
if err := os.WriteFile("document.txt.ots", otsData, 0644); err != nil {
fmt.Println("Failed to save upgraded OTS file:", err)
} else {
fmt.Println("Upgraded timestamp file saved")
}
}
// If all sequences are confirmed, we're done
if len(file.GetPendingSequences()) == 0 {
fmt.Println("All sequences are now confirmed in the Bitcoin blockchain!")
break
}
}
// Final report
fmt.Println("\nFinal timestamp status:")
confirmedSeqs := file.GetBitcoinAttestedSequences()
pendingSeqs := file.GetPendingSequences()
fmt.Printf("Confirmed attestations: %d\n", len(confirmedSeqs))
for _, seq := range confirmedSeqs {
att := seq.GetAttestation()
fmt.Printf("- Confirmed in Bitcoin block %d\n", att.BitcoinBlockHeight)
}
fmt.Printf("Pending attestations: %d\n", len(pendingSeqs))
for _, seq := range pendingSeqs {
att := seq.GetAttestation()
fmt.Printf("- Still pending at %s\n", att.CalendarServerURL)
}
fmt.Println("\nDetailed timestamp info:")
fmt.Println(file.Human(true))
}
```
You can also take a look at [`ots`](https://github.com/fiatjaf/ots), a simple CLI to OpenTimestamps which is basically a wrapper over this library.
This repository includes an `ots` command line tool which provides a convenient interface to the core library functionality. Run it without arguments to see usage information.
You can also take a look at the original [`ots`](https://github.com/fiatjaf/ots) CLI which is another implementation based on the same concepts.
# License

158
cmd/example/main.go Normal file
View File

@@ -0,0 +1,158 @@
package main
import (
"context"
"crypto/sha256"
"fmt"
"os"
"time"
"git.intruders.space/public/opentimestamps"
"git.intruders.space/public/opentimestamps/ots"
)
func main() {
// Read a file to timestamp
fileData, err := os.ReadFile("document.txt")
if err != nil {
fmt.Println("Error reading file:", err)
return
}
// Calculate the digest
digest := sha256.Sum256(fileData)
fmt.Printf("File digest: %x\n", digest)
// Define calendar servers
calendars := []string{
"https://alice.btc.calendar.opentimestamps.org",
"https://bob.btc.calendar.opentimestamps.org",
"https://finney.calendar.eternitywall.com",
}
// Create a timestamp using each calendar
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var sequences []ots.Sequence
for _, calendarURL := range calendars {
fmt.Printf("Submitting to %s...\n", calendarURL)
seq, err := opentimestamps.Stamp(ctx, calendarURL, digest)
if err != nil {
fmt.Printf("Failed to submit to %s: %v\n", calendarURL, err)
continue
}
fmt.Printf("Submission to %s successful\n", calendarURL)
sequences = append(sequences, seq)
}
if len(sequences) == 0 {
fmt.Println("Failed to create any timestamps")
return
}
// Create the timestamp file
file := &ots.File{
Digest: digest[:],
Sequences: sequences,
}
// Save the OTS file
otsData := file.SerializeToFile()
if err := os.WriteFile("document.txt.ots", otsData, 0644); err != nil {
fmt.Println("Failed to save OTS file:", err)
return
}
fmt.Println("Timestamp file created successfully")
// Display initial timestamp info
fmt.Println("\nInitial timestamp info:")
fmt.Println(file.Human(false))
// Attempt to upgrade the timestamp every 20 minutes
fmt.Println("\nWill check for upgrades every 20 minutes...")
maxAttempts := 12 // Try for about 4 hours (12 * 20 minutes)
for attempt := 0; attempt < maxAttempts; attempt++ {
if attempt > 0 {
fmt.Printf("\nWaiting 20 minutes before next upgrade attempt (%d/%d)...\n", attempt+1, maxAttempts)
time.Sleep(20 * time.Minute)
}
upgraded := false
pendingSequences := file.GetPendingSequences()
if len(pendingSequences) == 0 {
fmt.Println("No pending sequences to upgrade")
break
}
fmt.Printf("Attempting to upgrade %d pending sequences...\n", len(pendingSequences))
upgradeCtx, upgradeCancel := context.WithTimeout(context.Background(), 30*time.Second)
for _, seq := range pendingSequences {
att := seq.GetAttestation()
fmt.Printf("Trying to upgrade sequence from %s...\n", att.CalendarServerURL)
upgradedSeq, err := opentimestamps.UpgradeSequence(upgradeCtx, seq, digest[:])
if err != nil {
fmt.Printf("Failed to upgrade sequence from %s: %v\n", att.CalendarServerURL, err)
continue
}
// Replace the sequence in the file
for i, origSeq := range file.Sequences {
origAtt := origSeq.GetAttestation()
if origAtt.CalendarServerURL == att.CalendarServerURL {
file.Sequences[i] = upgradedSeq
upgraded = true
newAtt := upgradedSeq.GetAttestation()
if newAtt.BitcoinBlockHeight > 0 {
fmt.Printf("Sequence upgraded! Confirmed in Bitcoin block %d\n", newAtt.BitcoinBlockHeight)
} else {
fmt.Println("Sequence updated but still pending")
}
break
}
}
}
upgradeCancel()
if upgraded {
// Save the upgraded file
otsData = file.SerializeToFile()
if err := os.WriteFile("document.txt.ots", otsData, 0644); err != nil {
fmt.Println("Failed to save upgraded OTS file:", err)
} else {
fmt.Println("Upgraded timestamp file saved")
}
}
// If all sequences are confirmed, we're done
if len(file.GetPendingSequences()) == 0 {
fmt.Println("All sequences are now confirmed in the Bitcoin blockchain!")
break
}
}
// Final report
fmt.Println("\nFinal timestamp status:")
confirmedSeqs := file.GetBitcoinAttestedSequences()
pendingSeqs := file.GetPendingSequences()
fmt.Printf("Confirmed attestations: %d\n", len(confirmedSeqs))
for _, seq := range confirmedSeqs {
att := seq.GetAttestation()
fmt.Printf("- Confirmed in Bitcoin block %d\n", att.BitcoinBlockHeight)
}
fmt.Printf("Pending attestations: %d\n", len(pendingSeqs))
for _, seq := range pendingSeqs {
att := seq.GetAttestation()
fmt.Printf("- Still pending at %s\n", att.CalendarServerURL)
}
fmt.Println("\nDetailed timestamp info:")
fmt.Println(file.Human(true))
}

130
cmd/ots/create.go Normal file
View File

@@ -0,0 +1,130 @@
package main
import (
"context"
"crypto/sha256"
"fmt"
"log/slog"
"os"
"strings"
"time"
"git.intruders.space/public/opentimestamps"
"git.intruders.space/public/opentimestamps/ots"
"github.com/spf13/cobra"
)
// Default calendar servers
var defaultCalendars = []string{
"https://alice.btc.calendar.opentimestamps.org",
"https://bob.btc.calendar.opentimestamps.org",
"https://finney.calendar.eternitywall.com",
}
var (
// Create command flags
createOutput string
createCalendarsStr string
createTimeout time.Duration
)
// createCmd represents the create command
var createCmd = &cobra.Command{
Use: "create [flags] <file>",
Short: "Create a timestamp for a file",
Long: `Create a timestamp for a file by submitting its digest to OpenTimestamps calendar servers.
The resulting timestamp is saved to a .ots file, which can later be verified or upgraded.`,
Args: cobra.ExactArgs(1),
Run: runCreateCmd,
}
func init() {
// Local flags for the create command
createCmd.Flags().StringVarP(&createOutput, "output", "o", "", "Output filename (default: input filename with .ots extension)")
createCmd.Flags().StringVar(&createCalendarsStr, "calendar", strings.Join(defaultCalendars, ","), "Comma-separated list of calendar server URLs")
createCmd.Flags().DurationVar(&createTimeout, "timeout", 30*time.Second, "Timeout for calendar server connections")
}
func runCreateCmd(cmd *cobra.Command, args []string) {
inputPath := args[0]
// Determine output file path
outputPath := createOutput
if outputPath == "" {
outputPath = inputPath + ".ots"
}
// Parse calendar servers
calendars := strings.Split(createCalendarsStr, ",")
if len(calendars) == 0 {
calendars = defaultCalendars
}
// Read the input file
fileData, err := os.ReadFile(inputPath)
if err != nil {
slog.Error("Failed to read input file", "file", inputPath, "error", err)
os.Exit(1)
}
// Compute the file digest
digest := sha256.Sum256(fileData)
slog.Info("Computed file digest", "digest", fmt.Sprintf("%x", digest))
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), createTimeout)
defer cancel()
// Create timestamps using all calendar servers
var sequences []ots.Sequence
var errors []string
for _, calendarURL := range calendars {
calendarURL = strings.TrimSpace(calendarURL)
if calendarURL == "" {
continue
}
slog.Info("Submitting to calendar", "url", calendarURL)
seq, err := opentimestamps.Stamp(ctx, calendarURL, digest)
if err != nil {
slog.Warn("Calendar submission failed", "url", calendarURL, "error", err)
errors = append(errors, fmt.Sprintf("%s: %v", calendarURL, err))
continue
}
sequences = append(sequences, seq)
slog.Info("Calendar submission successful", "url", calendarURL)
}
if len(sequences) == 0 {
slog.Error("All calendar submissions failed", "errors", errors)
os.Exit(1)
}
// Create the timestamp file
file := &ots.File{
Digest: digest[:],
Sequences: sequences,
}
// Write the OTS file
otsData := file.SerializeToFile()
err = os.WriteFile(outputPath, otsData, 0644)
if err != nil {
slog.Error("Failed to write OTS file", "file", outputPath, "error", err)
os.Exit(1)
}
slog.Info("Timestamp file created successfully",
"file", outputPath,
"timestamps", len(sequences),
"size", len(otsData))
// Print human-readable representation
slog.Debug("Timestamp details", "info", file.Human(false))
slog.Info("Timestamp creation complete",
"status", "pending",
"note", "Use 'ots upgrade' to upgrade this timestamp when it's confirmed in the Bitcoin blockchain")
}

108
cmd/ots/info.go Normal file
View File

@@ -0,0 +1,108 @@
package main
import (
"encoding/hex"
"fmt"
"log/slog"
"os"
"strings"
"git.intruders.space/public/opentimestamps"
"git.intruders.space/public/opentimestamps/ots"
"github.com/spf13/cobra"
)
// infoCmd represents the info command
var infoCmd = &cobra.Command{
Use: "info [flags] <file.ots>",
Short: "Display information about a timestamp",
Long: `Display detailed information about a timestamp file in human-readable format.
Shows the file digest and the sequence of operations and attestations.`,
Args: cobra.ExactArgs(1),
Run: runInfoCmd,
}
func init() {
// No specific flags needed for info command
}
func runInfoCmd(cmd *cobra.Command, args []string) {
otsPath := args[0]
// Read and parse the OTS file
otsData, err := os.ReadFile(otsPath)
if err != nil {
slog.Error("Failed to read OTS file", "file", otsPath, "error", err)
os.Exit(1)
}
timestampFile, err := opentimestamps.ReadFromFile(otsData)
if err != nil {
slog.Error("Failed to parse OTS file", "file", otsPath, "error", err)
os.Exit(1)
}
// Print the timestamp information in the requested format
fmt.Printf("File sha256 hash: %s\n", hex.EncodeToString(timestampFile.Digest))
fmt.Println("Timestamp:")
// Format and print each sequence
for i, seq := range timestampFile.Sequences {
if i > 0 {
// Add a separator between sequences if needed
fmt.Println()
}
printSequenceInfo(seq, 0, timestampFile.Digest)
}
}
func printSequenceInfo(seq ots.Sequence, depth int, initialDigest []byte) {
prefix := strings.Repeat(" ", depth)
// Track the current result as we apply operations
current := initialDigest
// For the first level (depth 0), don't add the arrow
arrowPrefix := ""
if depth > 0 {
arrowPrefix = " -> "
}
for _, inst := range seq {
// Skip attestation for now, we'll handle it at the end
if inst.Attestation != nil {
continue
}
// Print operation
if inst.Operation != nil {
line := fmt.Sprintf("%s%s%s", prefix, arrowPrefix, inst.Operation.Name)
if inst.Operation.Binary {
line += fmt.Sprintf(" %s", hex.EncodeToString(inst.Argument))
}
fmt.Println(line)
// Update current result
current = inst.Operation.Apply(current, inst.Argument)
// Only show arrow prefix for the first line
arrowPrefix = ""
}
}
// Check if there's an attestation at the end
if len(seq) > 0 && seq[len(seq)-1].Attestation != nil {
att := seq[len(seq)-1].Attestation
var attLine string
if att.BitcoinBlockHeight > 0 {
attLine = fmt.Sprintf("verify BitcoinAttestation(block %d)", att.BitcoinBlockHeight)
} else if att.CalendarServerURL != "" {
attLine = fmt.Sprintf("verify PendingAttestation('%s')", att.CalendarServerURL)
}
if attLine != "" {
fmt.Printf("%s%s\n", prefix, attLine)
}
}
}

13
cmd/ots/main.go Normal file
View File

@@ -0,0 +1,13 @@
package main
import (
"fmt"
"os"
)
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}

99
cmd/ots/pdf.go Normal file
View File

@@ -0,0 +1,99 @@
package main
import (
"log/slog"
"os"
"git.intruders.space/public/opentimestamps"
"git.intruders.space/public/opentimestamps/pdf"
"github.com/spf13/cobra"
)
var (
// PDF command flags
pdfOutput string
pdfIncludeContent bool
pdfTitle string
pdfComment string
pdfEsploraURL string
)
// pdfCmd represents the pdf command
var pdfCmd = &cobra.Command{
Use: "pdf [flags] <file> <file.ots>",
Short: "Generate a PDF certificate for a timestamp",
Long: `Generate a PDF certificate that explains how to verify the timestamp.
The PDF includes all necessary information such as hashes, operations,
and instructions for manual verification.`,
Args: cobra.ExactArgs(2),
Run: runPdfCmd,
}
func init() {
// Local flags for the pdf command
pdfCmd.Flags().StringVarP(&pdfOutput, "output", "o", "", "Output PDF filename (default: original filename with .pdf extension)")
pdfCmd.Flags().BoolVar(&pdfIncludeContent, "include-content", false, "Include the original file content in the PDF (for text files only)")
pdfCmd.Flags().StringVar(&pdfTitle, "title", "OpenTimestamps Certificate", "Title for the PDF document")
pdfCmd.Flags().StringVar(&pdfComment, "comment", "", "Additional comment to include in the certificate")
pdfCmd.Flags().StringVar(&pdfEsploraURL, "esplora", "https://blockstream.info/api", "URL of Esplora API for fetching block information")
}
func runPdfCmd(cmd *cobra.Command, args []string) {
filePath := args[0]
otsPath := args[1]
// Determine output file path
outputPath := pdfOutput
if outputPath == "" {
outputPath = filePath + ".certificate.pdf"
}
// Read the original file
fileData, err := os.ReadFile(filePath)
if err != nil {
slog.Error("Failed to read file", "file", filePath, "error", err)
os.Exit(1)
}
// Read and parse the OTS file
otsData, err := os.ReadFile(otsPath)
if err != nil {
slog.Error("Failed to read OTS file", "file", otsPath, "error", err)
os.Exit(1)
}
timestampFile, err := opentimestamps.ReadFromFile(otsData)
if err != nil {
slog.Error("Failed to parse OTS file", "file", otsPath, "error", err)
os.Exit(1)
}
// Set up PDF options from command line flags
options := pdf.CertificateOptions{
Title: pdfTitle,
Comment: pdfComment,
IncludeContent: pdfIncludeContent,
EsploraURL: pdfEsploraURL,
}
// Generate the PDF
pdfBuffer, err := pdf.GenerateCertificate(
filePath,
fileData,
timestampFile,
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)
}

65
cmd/ots/root.go Normal file
View File

@@ -0,0 +1,65 @@
package main
import (
"log/slog"
"os"
"strings"
"github.com/spf13/cobra"
)
var (
// Global flags
jsonLog bool
logLevel string
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "ots",
Short: "OpenTimestamps CLI tool",
Long: `A command-line interface for OpenTimestamps operations.
It allows creating, verifying and upgrading timestamps for files.`,
PersistentPreRun: setupLogging,
}
func init() {
// Global flags for all commands
rootCmd.PersistentFlags().BoolVar(&jsonLog, "json", false, "Use JSON format for logging")
rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Log level: debug, info, warn, error")
// Add subcommands
rootCmd.AddCommand(createCmd)
rootCmd.AddCommand(verifyCmd)
rootCmd.AddCommand(upgradeCmd)
rootCmd.AddCommand(infoCmd)
rootCmd.AddCommand(pdfCmd)
}
// setupLogging configures the global logger based on the flags
func setupLogging(cmd *cobra.Command, args []string) {
loggerOptions := slog.HandlerOptions{}
switch strings.ToLower(logLevel) {
case "debug":
loggerOptions.Level = slog.LevelDebug
case "info":
loggerOptions.Level = slog.LevelInfo
case "warn":
loggerOptions.Level = slog.LevelWarn
case "error":
loggerOptions.Level = slog.LevelError
default:
loggerOptions.Level = slog.LevelInfo
}
var logHandler slog.Handler
if jsonLog {
logHandler = slog.NewJSONHandler(os.Stdout, &loggerOptions)
} else {
logHandler = slog.NewTextHandler(os.Stdout, &loggerOptions)
}
logger := slog.New(logHandler)
slog.SetDefault(logger)
}

138
cmd/ots/upgrade.go Normal file
View File

@@ -0,0 +1,138 @@
package main
import (
"context"
"log/slog"
"os"
"time"
"git.intruders.space/public/opentimestamps"
"github.com/spf13/cobra"
)
var (
// Upgrade command flags
upgradeOutput string
upgradeTimeout time.Duration
upgradeDryRun bool
)
// upgradeCmd represents the upgrade command
var upgradeCmd = &cobra.Command{
Use: "upgrade [flags] <file.ots>",
Short: "Upgrade a timestamp",
Long: `Upgrade a timestamp by checking if pending attestations have been confirmed in the Bitcoin blockchain.
If confirmed, the timestamp will be updated with the Bitcoin block information.`,
Args: cobra.ExactArgs(1),
Run: runUpgradeCmd,
}
func init() {
// Local flags for the upgrade command
upgradeCmd.Flags().StringVarP(&upgradeOutput, "output", "o", "", "Output filename (default: overwrites the input file)")
upgradeCmd.Flags().DurationVar(&upgradeTimeout, "timeout", 30*time.Second, "Timeout for calendar server connections")
upgradeCmd.Flags().BoolVar(&upgradeDryRun, "dry-run", false, "Don't write output file, just check if upgrade is possible")
}
func runUpgradeCmd(cmd *cobra.Command, args []string) {
inputPath := args[0]
// Determine output file path
outputPath := upgradeOutput
if outputPath == "" {
outputPath = inputPath
}
// Read and parse the OTS file
otsData, err := os.ReadFile(inputPath)
if err != nil {
slog.Error("Failed to read OTS file", "file", inputPath, "error", err)
os.Exit(1)
}
timestampFile, err := opentimestamps.ReadFromFile(otsData)
if err != nil {
slog.Error("Failed to parse OTS file", "file", inputPath, "error", err)
os.Exit(1)
}
// Get pending sequences to upgrade
pendingSequences := timestampFile.GetPendingSequences()
if len(pendingSequences) == 0 {
slog.Info("No pending timestamps found, file is already fully upgraded", "file", inputPath)
os.Exit(0)
}
slog.Info("Found pending timestamps", "count", len(pendingSequences))
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), upgradeTimeout)
defer cancel()
// Try to upgrade each pending sequence
upgradedCount := 0
for i, seq := range pendingSequences {
att := seq.GetAttestation()
slog.Info("Attempting to upgrade timestamp",
"index", i+1,
"calendar", att.CalendarServerURL)
upgraded, err := opentimestamps.UpgradeSequence(ctx, seq, timestampFile.Digest)
if err != nil {
slog.Warn("Upgrade failed",
"calendar", att.CalendarServerURL,
"error", err)
continue
}
// Replace the pending sequence with the upgraded one
for j, origSeq := range timestampFile.Sequences {
if origSeq[len(origSeq)-1].Attestation != nil &&
origSeq[len(origSeq)-1].Attestation.CalendarServerURL == att.CalendarServerURL {
timestampFile.Sequences[j] = upgraded
break
}
}
upgradedCount++
newAtt := upgraded.GetAttestation()
if newAtt.BitcoinBlockHeight > 0 {
slog.Info("Timestamp upgraded successfully",
"calendar", att.CalendarServerURL,
"block", newAtt.BitcoinBlockHeight)
} else {
slog.Info("Timestamp replaced but still pending", "calendar", att.CalendarServerURL)
}
}
if upgradedCount == 0 {
slog.Warn("No timestamps could be upgraded at this time. Try again later.", "file", inputPath)
if !upgradeDryRun {
os.Exit(1)
}
os.Exit(0)
}
// In dry run mode, don't write the file
if upgradeDryRun {
slog.Info("Dry run completed", "upgraded", upgradedCount, "total", len(pendingSequences))
os.Exit(0)
}
// Write the updated OTS file
newOtsData := timestampFile.SerializeToFile()
err = os.WriteFile(outputPath, newOtsData, 0644)
if err != nil {
slog.Error("Failed to write updated OTS file", "file", outputPath, "error", err)
os.Exit(1)
}
slog.Info("Timestamp file upgraded successfully",
"file", outputPath,
"upgraded", upgradedCount,
"total", len(pendingSequences))
// Print human-readable representation
slog.Debug("Updated timestamp details", "info", timestampFile.Human(false))
}

170
cmd/ots/verify.go Normal file
View File

@@ -0,0 +1,170 @@
package main
import (
"crypto/sha256"
"fmt"
"log/slog"
"os"
"strings"
"time"
"git.intruders.space/public/opentimestamps"
"git.intruders.space/public/opentimestamps/verifyer"
"github.com/btcsuite/btcd/rpcclient"
"github.com/spf13/cobra"
)
var (
// Verify command flags
verifyEsploraURL string
verifyBitcoindHost string
verifyBitcoindUser string
verifyBitcoindPass string
verifyTimeout time.Duration
)
// verifyCmd represents the verify command
var verifyCmd = &cobra.Command{
Use: "verify [flags] <file> <file.ots>",
Short: "Verify a timestamp",
Long: `Verify a timestamp against the Bitcoin blockchain.
It computes the file digest, checks it against the timestamp,
and verifies the timestamp against Bitcoin using either Esplora API or bitcoind.`,
Args: cobra.ExactArgs(2),
Run: runVerifyCmd,
}
func init() {
// Local flags for the verify command
verifyCmd.Flags().StringVar(&verifyEsploraURL, "esplora", "https://blockstream.info/api", "URL of Esplora API")
verifyCmd.Flags().StringVar(&verifyBitcoindHost, "bitcoind", "", "Host:port of bitcoind RPC (e.g. localhost:8332)")
verifyCmd.Flags().StringVar(&verifyBitcoindUser, "rpcuser", "", "Bitcoin RPC username")
verifyCmd.Flags().StringVar(&verifyBitcoindPass, "rpcpass", "", "Bitcoin RPC password")
verifyCmd.Flags().DurationVar(&verifyTimeout, "timeout", 30*time.Second, "Connection timeout")
}
func runVerifyCmd(cmd *cobra.Command, args []string) {
filePath := args[0]
otsPath := args[1]
// Read the original file
fileData, err := os.ReadFile(filePath)
if err != nil {
slog.Error("Failed to read file", "file", filePath, "error", err)
os.Exit(1)
}
// Compute the file digest
digest := sha256.Sum256(fileData)
slog.Debug("Computed file digest", "digest", digest)
// Read and parse the OTS file
otsData, err := os.ReadFile(otsPath)
if err != nil {
slog.Error("Failed to read OTS file", "file", otsPath, "error", err)
os.Exit(1)
}
timestampFile, err := opentimestamps.ReadFromFile(otsData)
if err != nil {
slog.Error("Failed to parse OTS file", "file", otsPath, "error", err)
os.Exit(1)
}
// Setup Bitcoin connection
var bitcoin verifyer.Bitcoin
if verifyBitcoindHost != "" {
if verifyBitcoindUser == "" || verifyBitcoindPass == "" {
slog.Error("Bitcoind RPC credentials required with --bitcoind")
os.Exit(1)
}
// Create bitcoind connection config
config := rpcclient.ConnConfig{
Host: verifyBitcoindHost,
User: verifyBitcoindUser,
Pass: verifyBitcoindPass,
HTTPPostMode: true,
DisableTLS: true,
}
btc, err := verifyer.NewBitcoindInterface(config)
if err != nil {
slog.Error("Failed to connect to bitcoind", "error", err)
os.Exit(1)
}
bitcoin = btc
slog.Info("Using bitcoind", "host", verifyBitcoindHost)
} else {
// Use Esplora API
bitcoin = verifyer.NewEsploraClient(verifyEsploraURL, verifyTimeout)
slog.Info("Using Esplora API", "url", verifyEsploraURL)
}
// Check if digests match
if string(digest[:]) != string(timestampFile.Digest) {
slog.Warn("File digest doesn't match timestamp digest",
"file_digest", digest,
"timestamp_digest", timestampFile.Digest)
}
// Get Bitcoin-attested sequences
bitcoinSequences := timestampFile.GetBitcoinAttestedSequences()
if len(bitcoinSequences) == 0 {
// If no Bitcoin sequences, check if there are pending sequences
pendingSequences := timestampFile.GetPendingSequences()
if len(pendingSequences) > 0 {
slog.Info("Timestamp is pending confirmation in the Bitcoin blockchain")
slog.Info("Use 'ots upgrade <file.ots>' to try upgrading it")
for _, seq := range pendingSequences {
att := seq.GetAttestation()
slog.Info("Pending at calendar", "url", att.CalendarServerURL)
}
} else {
slog.Error("No valid attestations found in this timestamp")
}
os.Exit(1)
}
// Verify each Bitcoin-attested sequence
verificationSuccess := false
for i, seq := range bitcoinSequences {
slog.Info("Verifying sequence", "index", i+1, "total", len(bitcoinSequences))
tx, err := seq.Verify(bitcoin, timestampFile.Digest)
if err != nil {
slog.Warn("Verification failed", "error", err)
continue
}
att := seq.GetAttestation()
// Get the block time
blockHash, err := bitcoin.GetBlockHash(int64(att.BitcoinBlockHeight))
blockTime := ""
if err == nil {
blockHeader, err := bitcoin.GetBlockHeader(blockHash)
if err == nil {
// Format the block time
blockTime = fmt.Sprintf(" (%s)", blockHeader.Timestamp.Format(time.RFC3339))
}
}
slog.Info("Verification successful",
"block_height", att.BitcoinBlockHeight,
"block_time", strings.TrimPrefix(blockTime, " "))
if tx != nil {
slog.Info("Bitcoin transaction", "txid", tx.TxHash().String())
}
verificationSuccess = true
}
if !verificationSuccess {
slog.Error("All verification attempts failed")
os.Exit(1)
}
slog.Info("Timestamp successfully verified")
}

View File

@@ -1,2 +0,0 @@
The timestamp on this file is well-formatted, but will fail Bitcoin block
header validation.

Binary file not shown.

View File

Binary file not shown.

View File

@@ -1 +0,0 @@
Hello World!

Binary file not shown.

View File

@@ -1 +0,0 @@
The timestamp on this file is incomplete, and can be upgraded.

Binary file not shown.

View File

@@ -1,2 +0,0 @@
This file's timestamp has two attestations, one from a known notary, and one
from an unknown notary.

View File

@@ -1,2 +0,0 @@
This file is one of three different files that have been timestamped together
with a single merkle tree. (1/3)

Binary file not shown.

View File

@@ -1,2 +0,0 @@
This file is one of three different files that have been timestamped together
with a single merkle tree. (2/3)

Binary file not shown.

View File

@@ -1,2 +0,0 @@
This file is one of three different files that have been timestamped together
with a single merkle tree. (3/3)

Binary file not shown.

View File

@@ -1 +0,0 @@
This file has an (incomplete) timestamp with two different calendars.

Binary file not shown.

View File

@@ -1 +0,0 @@
This file's timestamp has a single attestation from an unknown notary.

Binary file not shown.

View File

@@ -0,0 +1 @@
earth is flat

Binary file not shown.

Binary file not shown.

31
go.mod
View File

@@ -1,21 +1,28 @@
module github.com/nbd-wtf/opentimestamps
module git.intruders.space/public/opentimestamps
go 1.21
go 1.24.2
require (
github.com/btcsuite/btcd v0.23.4
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1
golang.org/x/crypto v0.13.0
github.com/btcsuite/btcd v0.24.2
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0
github.com/jung-kurt/gofpdf v1.16.2
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.36.0
)
require (
github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect
github.com/btcsuite/btcd/btcutil v1.1.0 // indirect
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
github.com/btcsuite/btcd/btcutil v1.1.6 // indirect
github.com/btcsuite/btclog v1.0.0 // indirect
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect
github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/stretchr/testify v1.8.4 // indirect
golang.org/x/sys v0.12.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/sys v0.32.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

66
go.sum
View File

@@ -1,18 +1,26 @@
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.4 h1:IzV6qqkfwbItOS/sg/aDfPDsjPP8twrCOE2R93hxMlQ=
github.com/btcsuite/btcd v0.23.4/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3 h1:xM/n3yIhHAhHy04z4i43C8p4ehixJZMsnrVJkgl+MTE=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0 h1:MO4klnGY+EWJdoWF12Wkuf4AWDBPMpZNeN/jRLrklUU=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c=
github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btclog v1.0.0 h1:sEkpKJMmfGiyZjADwEIgB1NSwMyfdD1FB8v6+w1T0Ns=
github.com/btcsuite/btclog v1.0.0/go.mod h1:w7xnGOhwT3lmrS4H3b/D1XAXxvh+tbhUm8xeHN2y3TQ=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd h1:R/opQEbFEy9JGkIguV40SvRY1uliPX8ifOvi6ICsFCw=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
@@ -23,13 +31,17 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
@@ -40,13 +52,21 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -57,15 +77,33 @@ github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -81,8 +119,8 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -95,11 +133,13 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

28
ots/attestation.go Normal file
View File

@@ -0,0 +1,28 @@
package ots
import "fmt"
type Attestation struct {
BitcoinBlockHeight uint64
CalendarServerURL string
}
func (att Attestation) Name() string {
if att.BitcoinBlockHeight != 0 {
return "bitcoin"
} else if att.CalendarServerURL != "" {
return "pending"
} else {
return "unknown/broken"
}
}
func (att Attestation) Human() string {
if att.BitcoinBlockHeight != 0 {
return fmt.Sprintf("bitcoin(%d)", att.BitcoinBlockHeight)
} else if att.CalendarServerURL != "" {
return fmt.Sprintf("pending(%s)", att.CalendarServerURL)
} else {
return "unknown/broken"
}
}

View File

@@ -1,4 +1,4 @@
package opentimestamps
package ots
import (
deprecated_ripemd160 "golang.org/x/crypto/ripemd160"

View File

@@ -1,59 +1,23 @@
package opentimestamps
package ots
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"slices"
"strings"
"github.com/btcsuite/btcd/wire"
"git.intruders.space/public/opentimestamps/varn"
)
/*
* Header magic bytes
* Designed to be give the user some information in a hexdump, while being identified as 'data' by the file utility.
* \x00OpenTimestamps\x00\x00Proof\x00\xbf\x89\xe2\xe8\x84\xe8\x92\x94
*/
var headerMagic = []byte{0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x00, 0x00, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x00, 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94}
// Header magic bytes
// Designed to be give the user some information in a hexdump, while being identified as 'data' by the file utility.
// \x00OpenTimestamps\x00\x00Proof\x00\xbf\x89\xe2\xe8\x84\xe8\x92\x94
var (
pendingMagic = []byte{0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e}
bitcoinMagic = []byte{0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01}
HeaderMagic = []byte{0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x00, 0x00, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x00, 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94}
PendingMagic = []byte{0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e}
BitcoinMagic = []byte{0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01}
)
type Operation struct {
Name string
Tag byte
Binary bool // it's an operation that takes one argument, otherwise takes none
Apply func(curr []byte, arg []byte) []byte
}
var tags = map[byte]*Operation{
0xf0: {"append", 0xf0, true, func(curr []byte, arg []byte) []byte {
result := make([]byte, len(curr)+len(arg))
copy(result[0:], curr)
copy(result[len(curr):], arg)
return result
}},
0xf1: {"prepend", 0xf1, true, func(curr []byte, arg []byte) []byte {
result := make([]byte, len(curr)+len(arg))
copy(result[0:], arg)
copy(result[len(arg):], curr)
return result
}},
0xf2: {"reverse", 0xf2, false, func(curr []byte, arg []byte) []byte { panic("reverse not implemented") }},
0xf3: {"hexlify", 0xf3, false, func(curr []byte, arg []byte) []byte { panic("hexlify not implemented") }},
0x02: {"sha1", 0x02, false, func(curr []byte, arg []byte) []byte { panic("sha1 not implemented") }},
0x03: {"ripemd160", 0x03, false, ripemd160},
0x08: {"sha256", 0x08, false, func(curr []byte, arg []byte) []byte {
v := sha256.Sum256(curr)
return v[:]
}},
0x67: {"keccak256", 0x67, false, func(curr []byte, arg []byte) []byte { panic("keccak256 not implemented") }},
}
// A File represents the parsed content of an .ots file: it has an initial digest and
// a series of sequences of instructions. Each sequence must be evaluated separately, applying the operations
// on top of each other, starting with the .Digest until they end on an attestation.
@@ -62,53 +26,6 @@ type File struct {
Sequences []Sequence
}
// a Instruction can be an operation like "append" or "prepend" (this will be the case when .Operation != nil)
// or an attestation (when .Attestation != nil).
// It will have a non-nil .Argument whenever the operation requires an argument.
type Instruction struct {
*Operation
Argument []byte
*Attestation
}
type Sequence []Instruction
func (seq Sequence) GetAttestation() Attestation {
if len(seq) == 0 {
return Attestation{}
}
att := seq[len(seq)-1]
if att.Attestation == nil {
return Attestation{}
}
return *att.Attestation
}
// Compute runs a sequence of operations on top of an initial digest and returns the result, which is often a
// Bitcoin block merkle root. It also tries to identify the point in the sequence in which an actual Bitcoin
// transaction is formed and parse that.
func (seq Sequence) Compute(initial []byte) (merkleRoot []byte, bitcoinTx *wire.MsgTx) {
current := initial
for i, inst := range seq {
if inst.Operation == nil {
break
}
// the first time we do a double-sha256 that is likely a bitcoin transaction
if bitcoinTx == nil &&
inst.Operation.Name == "sha256" &&
len(seq) > i+1 && seq[i+1].Operation != nil &&
seq[i+1].Operation.Name == "sha256" {
tx := &wire.MsgTx{}
tx.Deserialize(bytes.NewReader(current))
bitcoinTx = tx
}
current = inst.Operation.Apply(current, inst.Argument)
}
return current, bitcoinTx
}
func (ts File) GetPendingSequences() []Sequence {
bitcoin := ts.GetBitcoinAttestedSequences()
@@ -182,8 +99,8 @@ func (ts File) Human(withPartials bool) string {
func (ts File) SerializeToFile() []byte {
data := make([]byte, 0, 5050)
data = append(data, headerMagic...)
data = appendVarUint(data, 1)
data = append(data, HeaderMagic...)
data = varn.AppendVarUint(data, 1)
data = append(data, 0x08) // sha256
data = append(data, ts.Digest...)
data = append(data, ts.SerializeInstructionSequences()...)
@@ -203,7 +120,7 @@ func (ts File) SerializeInstructionSequences() []byte {
// keep an ordered slice of all the checkpoints we will potentially leave during our write journey for this sequence
checkpoints := make([]int, 0, len(sequences[s1]))
for s2 := s1 + 1; s2 < len(sequences); s2++ {
chp := getCommonPrefixIndex(sequences[s1], sequences[s2])
chp := GetCommonPrefixIndex(sequences[s1], sequences[s2])
if pos, found := slices.BinarySearch(checkpoints, chp); !found {
checkpoints = append(checkpoints, -1) // make room
copy(checkpoints[pos+1:], checkpoints[pos:]) // move elements to the right
@@ -236,7 +153,7 @@ func (ts File) SerializeInstructionSequences() []byte {
// write normal operation
result = append(result, inst.Operation.Tag)
if inst.Operation.Binary {
result = appendVarBytes(result, inst.Argument)
result = varn.AppendVarBytes(result, inst.Argument)
}
} else if inst.Attestation != nil {
// write attestation record
@@ -245,15 +162,15 @@ func (ts File) SerializeInstructionSequences() []byte {
// will use a new buffer for the actual attestation result
abuf := make([]byte, 0, 100)
if inst.BitcoinBlockHeight != 0 {
result = append(result, bitcoinMagic...) // this goes in the main result buffer
abuf = appendVarUint(abuf, inst.BitcoinBlockHeight)
result = append(result, BitcoinMagic...) // this goes in the main result buffer
abuf = varn.AppendVarUint(abuf, inst.BitcoinBlockHeight)
} else if inst.CalendarServerURL != "" {
result = append(result, pendingMagic...) // this goes in the main result buffer
abuf = appendVarBytes(abuf, []byte(inst.CalendarServerURL))
result = append(result, PendingMagic...) // this goes in the main result buffer
abuf = varn.AppendVarBytes(abuf, []byte(inst.CalendarServerURL))
} else {
panic(fmt.Sprintf("invalid attestation: %v", inst))
}
result = appendVarBytes(result, abuf) // we append that result as varbytes
result = varn.AppendVarBytes(result, abuf) // we append that result as varbytes
}
} else {
panic(fmt.Sprintf("invalid instruction: %v", inst))
@@ -263,27 +180,43 @@ func (ts File) SerializeInstructionSequences() []byte {
return result
}
type Attestation struct {
BitcoinBlockHeight uint64
CalendarServerURL string
}
func (att Attestation) Name() string {
if att.BitcoinBlockHeight != 0 {
return "bitcoin"
} else if att.CalendarServerURL != "" {
return "pending"
} else {
return "unknown/broken"
func ParseOTSFile(buf varn.Buffer) (*File, error) {
// read magic
// read version [1 byte]
// read crypto operation for file digest [1 byte]
// read file digest [32 byte (depends)]
if magic, err := buf.ReadBytes(len(HeaderMagic)); err != nil || !slices.Equal(HeaderMagic, magic) {
return nil, fmt.Errorf("invalid ots file header '%s': %w", magic, err)
}
}
func (att Attestation) Human() string {
if att.BitcoinBlockHeight != 0 {
return fmt.Sprintf("bitcoin(%d)", att.BitcoinBlockHeight)
} else if att.CalendarServerURL != "" {
return fmt.Sprintf("pending(%s)", att.CalendarServerURL)
} else {
return "unknown/broken"
if version, err := buf.ReadVarUint(); err != nil || version != 1 {
return nil, fmt.Errorf("invalid ots file version '%v': %w", version, err)
}
tag, err := buf.ReadByte()
if err != nil {
return nil, fmt.Errorf("failed to read operation byte: %w", err)
}
if op, err := ReadInstruction(buf, tag); err != nil || op.Operation.Name != "sha256" {
return nil, fmt.Errorf("invalid crypto operation '%v', only sha256 supported: %w", op, err)
}
// if we got here assume the digest is sha256
digest, err := buf.ReadBytes(32)
if err != nil {
return nil, fmt.Errorf("failed to read 32-byte digest: %w", err)
}
ts := &File{
Digest: digest,
}
if seqs, err := ParseTimestamp(buf); err != nil {
return nil, err
} else {
ts.Sequences = seqs
}
return ts, nil
}

View File

@@ -1,11 +1,32 @@
package opentimestamps
package ots
import (
"fmt"
"slices"
"strings"
"slices"
"git.intruders.space/public/opentimestamps/varn"
)
// a Instruction can be an operation like "append" or "prepend" (this will be the case when .Operation != nil)
// or an attestation (when .Attestation != nil).
// It will have a non-nil .Argument whenever the operation requires an argument.
type Instruction struct {
*Operation
Argument []byte
*Attestation
}
func GetCommonPrefixIndex(s1 []Instruction, s2 []Instruction) int {
n := min(len(s1), len(s2))
for i := 0; i < n; i++ {
if CompareInstructions(s1[i], s2[i]) != 0 {
return i
}
}
return n
}
// CompareInstructions returns negative if a<b, 0 if a=b and positive if a>b.
// It considers an operation smaller than an attestation, a pending attestation smaller than a Bitcoin attestation.
// It orders operations by their tag byte and then by their argument.
@@ -50,3 +71,24 @@ func CompareInstructions(a, b Instruction) int {
return 0
}
}
func ReadInstruction(buf varn.Buffer, tag byte) (*Instruction, error) {
op, ok := Tags[tag]
if !ok {
return nil, fmt.Errorf("unknown tag %v", tag)
}
inst := Instruction{
Operation: op,
}
if op.Binary {
val, err := buf.ReadVarBytes()
if err != nil {
return nil, fmt.Errorf("error reading argument: %w", err)
}
inst.Argument = val
}
return &inst, nil
}

34
ots/operation.go Normal file
View File

@@ -0,0 +1,34 @@
package ots
import "crypto/sha256"
type Operation struct {
Name string
Tag byte
Binary bool // it's an operation that takes one argument, otherwise takes none
Apply func(curr []byte, arg []byte) []byte
}
var Tags = map[byte]*Operation{
0xf0: {"append", 0xf0, true, func(curr []byte, arg []byte) []byte {
result := make([]byte, len(curr)+len(arg))
copy(result[0:], curr)
copy(result[len(curr):], arg)
return result
}},
0xf1: {"prepend", 0xf1, true, func(curr []byte, arg []byte) []byte {
result := make([]byte, len(curr)+len(arg))
copy(result[0:], arg)
copy(result[len(arg):], curr)
return result
}},
0xf2: {"reverse", 0xf2, false, func(curr []byte, arg []byte) []byte { panic("reverse not implemented") }},
0xf3: {"hexlify", 0xf3, false, func(curr []byte, arg []byte) []byte { panic("hexlify not implemented") }},
0x02: {"sha1", 0x02, false, func(curr []byte, arg []byte) []byte { panic("sha1 not implemented") }},
0x03: {"ripemd160", 0x03, false, ripemd160},
0x08: {"sha256", 0x08, false, func(curr []byte, arg []byte) []byte {
v := sha256.Sum256(curr)
return v[:]
}},
0x67: {"keccak256", 0x67, false, func(curr []byte, arg []byte) []byte { panic("keccak256 not implemented") }},
}

View File

@@ -1,66 +1,88 @@
package opentimestamps
package ots
import (
"bytes"
"fmt"
"io"
"slices"
"git.intruders.space/public/opentimestamps/varn"
"git.intruders.space/public/opentimestamps/verifyer"
"github.com/btcsuite/btcd/wire"
)
func parseCalendarServerResponse(buf Buffer) (Sequence, error) {
seqs, err := parseTimestamp(buf)
if err != nil {
return nil, err
}
if len(seqs) != 1 {
return nil, fmt.Errorf("invalid number of sequences obtained: %d", len(seqs))
}
type Sequence []Instruction
return seqs[0], nil
func (seq Sequence) GetAttestation() Attestation {
if len(seq) == 0 {
return Attestation{}
}
att := seq[len(seq)-1]
if att.Attestation == nil {
return Attestation{}
}
return *att.Attestation
}
func parseOTSFile(buf Buffer) (*File, error) {
// read magic
// read version [1 byte]
// read crypto operation for file digest [1 byte]
// read file digest [32 byte (depends)]
if magic, err := buf.readBytes(len(headerMagic)); err != nil || !slices.Equal(headerMagic, magic) {
return nil, fmt.Errorf("invalid ots file header '%s': %w", magic, err)
}
// Compute runs a sequence of operations on top of an initial digest and returns the result, which is often a
// Bitcoin block merkle root. It also tries to identify the point in the sequence in which an actual Bitcoin
// transaction is formed and parse that.
func (seq Sequence) Compute(initial []byte) (merkleRoot []byte, bitcoinTx *wire.MsgTx) {
current := initial
for i, inst := range seq {
if inst.Operation == nil {
break
}
if version, err := buf.readVarUint(); err != nil || version != 1 {
return nil, fmt.Errorf("invalid ots file version '%v': %w", version, err)
}
// the first time we do a double-sha256 that is likely a bitcoin transaction
if bitcoinTx == nil &&
inst.Operation.Name == "sha256" &&
len(seq) > i+1 && seq[i+1].Operation != nil &&
seq[i+1].Operation.Name == "sha256" {
tx := &wire.MsgTx{}
tx.Deserialize(bytes.NewReader(current))
bitcoinTx = tx
}
tag, err := buf.readByte()
if err != nil {
return nil, fmt.Errorf("failed to read operation byte: %w", err)
current = inst.Operation.Apply(current, inst.Argument)
}
if op, err := readInstruction(buf, tag); err != nil || op.Operation.Name != "sha256" {
return nil, fmt.Errorf("invalid crypto operation '%v', only sha256 supported: %w", op, err)
}
// if we got here assume the digest is sha256
digest, err := buf.readBytes(32)
if err != nil {
return nil, fmt.Errorf("failed to read 32-byte digest: %w", err)
}
ts := &File{
Digest: digest,
}
if seqs, err := parseTimestamp(buf); err != nil {
return nil, err
} else {
ts.Sequences = seqs
}
return ts, nil
return current, bitcoinTx
}
func parseTimestamp(buf Buffer) ([]Sequence, error) {
// Verify validates sequence of operations that starts with digest and ends on a Bitcoin attestation against
// an actual Bitcoin block, as given by the provided Bitcoin interface.
func (seq Sequence) Verify(bitcoin verifyer.Bitcoin, digest []byte) (*wire.MsgTx, error) {
if len(seq) == 0 {
return nil, fmt.Errorf("empty sequence")
}
att := seq[len(seq)-1]
if att.Attestation == nil || att.BitcoinBlockHeight == 0 {
return nil, fmt.Errorf("sequence doesn't include a bitcoin attestation")
}
blockHash, err := bitcoin.GetBlockHash(int64(att.BitcoinBlockHeight))
if err != nil {
return nil, fmt.Errorf("failed to get block %d hash: %w", att.BitcoinBlockHeight, err)
}
blockHeader, err := bitcoin.GetBlockHeader(blockHash)
if err != nil {
return nil, fmt.Errorf("failed to get block %s header: %w", blockHash, err)
}
merkleRoot := blockHeader.MerkleRoot[:]
result, tx := seq.Compute(digest)
if !bytes.Equal(result, merkleRoot) {
return nil, fmt.Errorf("sequence result '%x' doesn't match the bitcoin merkle root for block %d: %x",
result, att.BitcoinBlockHeight, merkleRoot)
}
return tx, nil
}
func ParseTimestamp(buf varn.Buffer) ([]Sequence, error) {
// read instructions
// if operation = push
// if 0x00 = attestation
@@ -83,7 +105,7 @@ func parseTimestamp(buf Buffer) ([]Sequence, error) {
// go read these tags
for {
tag, err := buf.readByte()
tag, err := buf.ReadByte()
if err != nil {
if err == io.EOF {
return seqs, nil
@@ -93,20 +115,20 @@ func parseTimestamp(buf Buffer) ([]Sequence, error) {
if tag == 0x00 {
// enter an attestation context
magic, err := buf.readBytes(8)
magic, err := buf.ReadBytes(8)
if err != nil {
return nil, fmt.Errorf("failed to read attestion magic bytes: %w", err)
}
this, err := buf.readVarBytes()
this, err := buf.ReadVarBytes()
if err != nil {
return nil, fmt.Errorf("failed to read attestation bytes: %w", err)
}
abuf := newBuffer(this)
abuf := varn.NewBuffer(this)
switch {
case slices.Equal(magic, pendingMagic):
val, err := abuf.readVarBytes()
case slices.Equal(magic, PendingMagic):
val, err := abuf.ReadVarBytes()
if err != nil {
return nil, fmt.Errorf("failed reading calendar server url: %w", err)
}
@@ -114,8 +136,8 @@ func parseTimestamp(buf Buffer) ([]Sequence, error) {
seqs[currInstructionsBlock],
Instruction{Attestation: &Attestation{CalendarServerURL: string(val)}},
)
case slices.Equal(magic, bitcoinMagic):
val, err := abuf.readVarUint()
case slices.Equal(magic, BitcoinMagic):
val, err := abuf.ReadVarUint()
if err != nil {
return nil, fmt.Errorf("failed reading bitcoin block number: %w", err)
}
@@ -144,7 +166,7 @@ func parseTimestamp(buf Buffer) ([]Sequence, error) {
checkpoints = append(checkpoints, chp)
} else {
// a new operation in this block
inst, err := readInstruction(buf, tag)
inst, err := ReadInstruction(buf, tag)
if err != nil {
return nil, fmt.Errorf("failed to read instruction: %w", err)
}
@@ -152,24 +174,3 @@ func parseTimestamp(buf Buffer) ([]Sequence, error) {
}
}
}
func readInstruction(buf Buffer, tag byte) (*Instruction, error) {
op, ok := tags[tag]
if !ok {
return nil, fmt.Errorf("unknown tag %v", tag)
}
inst := Instruction{
Operation: op,
}
if op.Binary {
val, err := buf.readVarBytes()
if err != nil {
return nil, fmt.Errorf("error reading argument: %w", err)
}
inst.Argument = val
}
return &inst, nil
}

343
pdf/certificate.go Normal file
View File

@@ -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 <file> <file.ots>"
- 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
}

View File

@@ -6,9 +6,15 @@ import (
"fmt"
"io"
"net/http"
"strings"
"git.intruders.space/public/opentimestamps/ots"
"git.intruders.space/public/opentimestamps/varn"
)
func Stamp(ctx context.Context, calendarUrl string, digest [32]byte) (Sequence, error) {
var httpClient = &http.Client{}
func Stamp(ctx context.Context, calendarUrl string, digest [32]byte) (ots.Sequence, error) {
body := bytes.NewBuffer(digest[:])
req, err := http.NewRequestWithContext(ctx, "POST", normalizeUrl(calendarUrl)+"/digest", body)
if err != nil {
@@ -18,7 +24,8 @@ func Stamp(ctx context.Context, calendarUrl string, digest [32]byte) (Sequence,
req.Header.Add("User-Agent", "github.com/fiatjaf/opentimestamps")
req.Header.Add("Accept", "application/vnd.opentimestamps.v1")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("'%s' request failed: %w", calendarUrl, err)
}
@@ -29,7 +36,7 @@ func Stamp(ctx context.Context, calendarUrl string, digest [32]byte) (Sequence,
}
resp.Body.Close()
seq, err := parseCalendarServerResponse(newBuffer(full))
seq, err := parseCalendarServerResponse(varn.NewBuffer(full))
if err != nil {
return nil, fmt.Errorf("failed to parse response from '%s': %w", calendarUrl, err)
}
@@ -37,11 +44,11 @@ func Stamp(ctx context.Context, calendarUrl string, digest [32]byte) (Sequence,
return seq, nil
}
func ReadFromFile(data []byte) (*File, error) {
return parseOTSFile(newBuffer(data))
func ReadFromFile(data []byte) (*ots.File, error) {
return ots.ParseOTSFile(varn.NewBuffer(data))
}
func UpgradeSequence(ctx context.Context, seq Sequence, initial []byte) (Sequence, error) {
func UpgradeSequence(ctx context.Context, seq ots.Sequence, initial []byte) (ots.Sequence, error) {
result, _ := seq.Compute(initial)
attestation := seq.GetAttestation()
@@ -54,7 +61,8 @@ func UpgradeSequence(ctx context.Context, seq Sequence, initial []byte) (Sequenc
req.Header.Add("User-Agent", "github.com/fiatjaf/opentimestamps")
req.Header.Add("Accept", "application/vnd.opentimestamps.v1")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("'%s' request failed: %w", attestation.CalendarServerURL, err)
}
@@ -69,14 +77,34 @@ func UpgradeSequence(ctx context.Context, seq Sequence, initial []byte) (Sequenc
}
resp.Body.Close()
tail, err := parseCalendarServerResponse(newBuffer(body))
tail, err := parseCalendarServerResponse(varn.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("failed to parse response from '%s': %w", attestation.CalendarServerURL, err)
}
newSeq := make(Sequence, len(seq)+len(tail)-1)
newSeq := make(ots.Sequence, len(seq)+len(tail)-1)
copy(newSeq, seq[0:len(seq)-1])
copy(newSeq[len(seq)-1:], tail)
return newSeq, nil
}
func parseCalendarServerResponse(buf varn.Buffer) (ots.Sequence, error) {
seqs, err := ots.ParseTimestamp(buf)
if err != nil {
return nil, err
}
if len(seqs) != 1 {
return nil, fmt.Errorf("invalid number of sequences obtained: %d", len(seqs))
}
return seqs[0], nil
}
func normalizeUrl(u string) string {
u = strings.TrimSuffix(u, "/")
if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
u = "http://" + u
}
return u
}

111
utils.go
View File

@@ -1,111 +0,0 @@
package opentimestamps
import (
"io"
"strings"
)
func normalizeUrl(u string) string {
if strings.HasSuffix(u, "/") {
u = u[0 : len(u)-1]
}
if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
u = "http://" + u
}
return u
}
type Buffer struct {
pos *int
buf []byte
}
func newBuffer(buf []byte) Buffer {
zero := 0
return Buffer{&zero, buf}
}
func (buf Buffer) readBytes(n int) ([]byte, error) {
// fmt.Println("reading", n, "bytes")
if *buf.pos >= len(buf.buf) {
return nil, io.EOF
}
res := buf.buf[*buf.pos : *buf.pos+n]
*buf.pos = *buf.pos + n
// fmt.Println("->", hex.EncodeToString(res))
return res, nil
}
func (buf Buffer) readByte() (byte, error) {
b, err := buf.readBytes(1)
if err != nil {
return 0, err
}
return b[0], nil
}
func (buf Buffer) readVarUint() (uint64, error) {
var value uint64 = 0
var shift uint64 = 0
for {
b, err := buf.readByte()
if err != nil {
return 0, err
}
value |= (uint64(b) & 0b01111111) << shift
shift += 7
if b&0b10000000 == 0 {
break
}
}
return value, nil
}
func (buf Buffer) readVarBytes() ([]byte, error) {
v, err := buf.readVarUint()
if err != nil {
return nil, err
}
b, err := buf.readBytes(int(v))
if err != nil {
return nil, err
}
return b, nil
}
func appendVarUint(buf []byte, value uint64) []byte {
if value == 0 {
buf = append(buf, 0)
} else {
for value != 0 {
b := byte(value & 0b01111111)
if value > 0b01111111 {
b |= 0b10000000
}
buf = append(buf, b)
if value <= 0b01111111 {
break
}
value >>= 7
}
}
return buf
}
func appendVarBytes(buf []byte, value []byte) []byte {
buf = appendVarUint(buf, uint64(len(value)))
buf = append(buf, value...)
return buf
}
func getCommonPrefixIndex(s1 []Instruction, s2 []Instruction) int {
n := min(len(s1), len(s2))
for i := 0; i < n; i++ {
if CompareInstructions(s1[i], s2[i]) != 0 {
return i
}
}
return n
}

81
varn/buffer.go Normal file
View File

@@ -0,0 +1,81 @@
package varn
import "io"
// Buffer is a simple buffer reader that allows reading bytes and
// variable-length integers. It is not thread-safe and should not be used
// concurrently.
type Buffer struct {
pos *int
buf []byte
}
// NewBuffer creates a new Buffer instance.
func NewBuffer(buf []byte) Buffer {
zero := 0
return Buffer{&zero, buf}
}
// ReadBytes reads n bytes from the buffer.
func (buf Buffer) ReadBytes(n int) ([]byte, error) {
if *buf.pos >= len(buf.buf) {
return nil, io.EOF
}
if *buf.pos+n > len(buf.buf) {
return nil, io.ErrUnexpectedEOF
}
res := buf.buf[*buf.pos : *buf.pos+n]
*buf.pos = *buf.pos + n
return res, nil
}
func (buf Buffer) ReadByte() (byte, error) {
b, err := buf.ReadBytes(1)
if err != nil {
return 0, err
}
return b[0], nil
}
// ReadVarUint reads a variable-length unsigned integer from the buffer.
// Returns io.EOF if the end of the buffer is reached before a complete integer
// is read.
func (buf Buffer) ReadVarUint() (uint64, error) {
var value uint64 = 0
var shift uint64 = 0
for {
b, err := buf.ReadByte()
if err != nil {
return 0, err
}
value |= (uint64(b) & 0b01111111) << shift
shift += 7
if b&0b10000000 == 0 {
break
}
}
return value, nil
}
// ReadVarBytes reads a variable-length byte array from the buffer.
// It first reads a variable-length unsigned integer to determine the length
// of the byte array, and then reads that many bytes from the buffer.
// Returns the byte array and an error if any occurs during reading.
// The function will return io.EOF if the end of the buffer is reached before
// the specified number of bytes is read.
// If the length of the byte array is 0, it will return an empty byte slice.
// If the length is greater than the remaining bytes in the buffer, it will
// return io.EOF.
func (buf Buffer) ReadVarBytes() ([]byte, error) {
v, err := buf.ReadVarUint()
if err != nil {
return nil, err
}
b, err := buf.ReadBytes(int(v))
if err != nil {
return nil, err
}
return b, nil
}

155
varn/buffer_test.go Normal file
View File

@@ -0,0 +1,155 @@
package varn_test
import (
"io"
"testing"
"github.com/stretchr/testify/assert"
"git.intruders.space/public/opentimestamps/varn"
)
func TestNewBuffer(t *testing.T) {
data := []byte{0x01, 0x02, 0x03}
buf := varn.NewBuffer(data)
// Test the first byte to verify initialization
b, err := buf.ReadByte()
assert.NoError(t, err)
assert.Equal(t, byte(0x01), b)
// Test reading the second byte
b, err = buf.ReadByte()
assert.NoError(t, err)
assert.Equal(t, byte(0x02), b)
// Test reading the third byte
b, err = buf.ReadByte()
assert.NoError(t, err)
assert.Equal(t, byte(0x03), b)
// Test reading beyond the buffer
_, err = buf.ReadByte()
assert.ErrorIs(t, err, io.EOF)
// Test reading from an empty buffer
emptyBuf := varn.NewBuffer([]byte{})
_, err = emptyBuf.ReadByte()
assert.ErrorIs(t, err, io.EOF)
// Test reading from a nil buffer
nilBuf := varn.NewBuffer(nil)
_, err = nilBuf.ReadByte()
assert.ErrorIs(t, err, io.EOF)
// Test reading from a buffer with a single byte
singleByteBuf := varn.NewBuffer([]byte{0xFF})
b, err = singleByteBuf.ReadByte()
assert.NoError(t, err)
assert.Equal(t, byte(0xFF), b)
}
func TestReadBytes(t *testing.T) {
data := []byte{0x01, 0x02, 0x03, 0x04, 0x05}
buf := varn.NewBuffer(data)
// Read 3 bytes
bytes, err := buf.ReadBytes(3)
assert.NoError(t, err)
assert.Equal(t, []byte{0x01, 0x02, 0x03}, bytes)
// Read 2 more bytes
bytes, err = buf.ReadBytes(2)
assert.NoError(t, err)
assert.Equal(t, []byte{0x04, 0x05}, bytes)
// Try to read beyond the buffer
_, err = buf.ReadBytes(1)
assert.ErrorIs(t, err, io.EOF)
}
func TestReadByte(t *testing.T) {
data := []byte{0x0A, 0x0B}
buf := varn.NewBuffer(data)
// Read first byte
b, err := buf.ReadByte()
assert.NoError(t, err)
assert.Equal(t, byte(0x0A), b)
// Read second byte
b, err = buf.ReadByte()
assert.NoError(t, err)
assert.Equal(t, byte(0x0B), b)
// Try to read beyond the buffer
_, err = buf.ReadByte()
assert.ErrorIs(t, err, io.EOF)
}
func TestReadVarUint(t *testing.T) {
testCases := []struct {
name string
input []byte
expected uint64
}{
{
name: "Single byte",
input: []byte{0x01},
expected: 1,
},
{
name: "Two bytes",
input: []byte{0x81, 0x01}, // 129 in varint encoding
expected: 129,
},
{
name: "Large number",
input: []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x01}, // 536870911 in varint encoding
expected: 536870911,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
buf := varn.NewBuffer(tc.input)
val, err := buf.ReadVarUint()
assert.NoError(t, err)
assert.Equal(t, tc.expected, val)
})
}
// Test EOF
buf := varn.NewBuffer([]byte{0x81}) // Incomplete varint
_, err := buf.ReadVarUint()
assert.ErrorIs(t, err, io.EOF)
}
func TestReadVarBytes(t *testing.T) {
// Test normal case: length followed by data
data := []byte{0x03, 0x0A, 0x0B, 0x0C, 0x0D}
buf := varn.NewBuffer(data)
bytes, err := buf.ReadVarBytes()
assert.NoError(t, err)
assert.Equal(t, []byte{0x0A, 0x0B, 0x0C}, bytes)
// Test empty array
data = []byte{0x00, 0x01}
buf = varn.NewBuffer(data)
bytes, err = buf.ReadVarBytes()
assert.NoError(t, err)
assert.Equal(t, []byte{}, bytes)
// Test EOF during length read
buf = varn.NewBuffer([]byte{})
_, err = buf.ReadVarBytes()
assert.ErrorIs(t, err, io.EOF)
// Test error during data read (insufficient bytes)
data = []byte{0x03, 0x01}
buf = varn.NewBuffer(data)
_, err = buf.ReadVarBytes()
assert.ErrorIs(t, err, io.ErrUnexpectedEOF)
}

38
varn/varn.go Normal file
View File

@@ -0,0 +1,38 @@
// Package varn implements variable-length encoding for unsigned integers and
// byte slices. It is used in the OpenTimestamps protocol to encode
// instructions and attestations. The encoding is similar to the one used in
// Protocol Buffers, but with a different format for the variable-length
// integers. The encoding is designed to be compact and efficient, while still
// being easy to decode. The package provides functions to read and write
// variable-length integers and byte slices, as well as a Buffer type for
// reading and writing data in a more convenient way. The package is not
// thread-safe and should not be used concurrently. It is intended for use in
// the OpenTimestamps protocol and is not a general-purpose encoding library.
package varn
// AppendVarUint appends a variable-length unsigned integer to the buffer
func AppendVarUint(buf []byte, value uint64) []byte {
if value == 0 {
buf = append(buf, 0)
} else {
for value != 0 {
b := byte(value & 0b01111111)
if value > 0b01111111 {
b |= 0b10000000
}
buf = append(buf, b)
if value <= 0b01111111 {
break
}
value >>= 7
}
}
return buf
}
func AppendVarBytes(buf []byte, value []byte) []byte {
buf = AppendVarUint(buf, uint64(len(value)))
buf = append(buf, value...)
return buf
}

118
varn/varn_test.go Normal file
View File

@@ -0,0 +1,118 @@
package varn_test
import (
"bytes"
"encoding/hex"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.intruders.space/public/opentimestamps/varn"
)
// Helper functions for decoding (to verify encoding)
func readVarUint(t *testing.T, data []byte) (uint64, int) {
t.Helper()
require.NotZero(t, data, "empty buffer")
if data[0] == 0 {
return 0, 1
}
var value uint64
var bytesRead int
for i, b := range data {
bytesRead = i + 1
value |= uint64(b&0x7F) << uint(7*i)
if b&0x80 == 0 {
break
}
require.Less(t, i, 9, "varint too long") // 9 is max bytes needed for uint64
}
return value, bytesRead
}
func readVarBytes(t *testing.T, data []byte) ([]byte, int) {
t.Helper()
length, bytesRead := readVarUint(t, data)
require.GreaterOrEqual(t, uint64(len(data)-bytesRead), length, "buffer too short")
end := bytesRead + int(length)
return data[bytesRead:end], end
}
func TestAppendVarUint(t *testing.T) {
tests := []struct {
name string
value uint64
expected string // hex representation of expected bytes
}{
{"zero", 0, "00"},
{"small value", 42, "2a"},
{"value below 128", 127, "7f"},
{"value requiring 2 bytes", 128, "8001"},
{"medium value", 300, "ac02"},
{"large value", 1234567890, "d285d8cc04"}, // Updated from "d2a6e58e07" to correct encoding
{"max uint64", 18446744073709551615, "ffffffffffffffffff01"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf []byte
result := varn.AppendVarUint(buf, tt.value)
// Check encoding
expected, err := hex.DecodeString(tt.expected)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Verify by decoding
decoded, _ := readVarUint(t, result)
assert.Equal(t, tt.value, decoded)
})
}
}
func TestAppendVarBytes(t *testing.T) {
tests := []struct {
name string
value []byte
}{
{"empty", []byte{}},
{"small", []byte{0x01, 0x02, 0x03}},
{"medium", bytes.Repeat([]byte{0xAA}, 127)},
{"large", bytes.Repeat([]byte{0xBB}, 256)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf []byte
result := varn.AppendVarBytes(buf, tt.value)
// Verify by decoding
decoded, _ := readVarBytes(t, result)
assert.Equal(t, tt.value, decoded)
// Check that length is properly encoded
lenEncoded, _ := readVarUint(t, result)
assert.Equal(t, uint64(len(tt.value)), lenEncoded)
})
}
}
func TestAppendingToExistingBuffer(t *testing.T) {
existing := []byte{0xFF, 0xFF}
// Test AppendVarUint
result := varn.AppendVarUint(existing, 42)
assert.Equal(t, []byte{0xFF, 0xFF, 0x2A}, result)
// Test AppendVarBytes
data := []byte{0x01, 0x02, 0x03}
result = varn.AppendVarBytes(existing, data)
assert.Equal(t, []byte{0xFF, 0xFF, 0x03, 0x01, 0x02, 0x03}, result)
}

View File

@@ -1,47 +0,0 @@
package opentimestamps
import (
"bytes"
"fmt"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
)
type Bitcoin interface {
GetBlockHash(height int64) (*chainhash.Hash, error)
GetBlockHeader(hash *chainhash.Hash) (*wire.BlockHeader, error)
}
// Verify validates sequence of operations that starts with digest and ends on a Bitcoin attestation against
// an actual Bitcoin block, as given by the provided Bitcoin interface.
func (seq Sequence) Verify(bitcoin Bitcoin, digest []byte) (*wire.MsgTx, error) {
if len(seq) == 0 {
return nil, fmt.Errorf("empty sequence")
}
att := seq[len(seq)-1]
if att.Attestation == nil || att.BitcoinBlockHeight == 0 {
return nil, fmt.Errorf("sequence doesn't include a bitcoin attestation")
}
blockHash, err := bitcoin.GetBlockHash(int64(att.BitcoinBlockHeight))
if err != nil {
return nil, fmt.Errorf("failed to get block %d hash: %w", att.BitcoinBlockHeight, err)
}
blockHeader, err := bitcoin.GetBlockHeader(blockHash)
if err != nil {
return nil, fmt.Errorf("failed to get block %s header: %w", blockHash, err)
}
merkleRoot := blockHeader.MerkleRoot[:]
result, tx := seq.Compute(digest)
if !bytes.Equal(result, merkleRoot) {
return nil, fmt.Errorf("sequence result '%x' doesn't match the bitcoin merkle root for block %d: %x",
result, att.BitcoinBlockHeight, merkleRoot)
}
return tx, nil
}

15
verifyer/bitcoin.go Normal file
View File

@@ -0,0 +1,15 @@
package verifyer
import (
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/rpcclient"
"github.com/btcsuite/btcd/wire"
)
type Bitcoin interface {
GetBlockHash(height int64) (*chainhash.Hash, error)
GetBlockHeader(hash *chainhash.Hash) (*wire.BlockHeader, error)
}
var _ Bitcoin = (*esplora)(nil)
var _ Bitcoin = (*rpcclient.Client)(nil)

View File

@@ -1,4 +1,4 @@
package opentimestamps
package verifyer
import "github.com/btcsuite/btcd/rpcclient"

View File

@@ -1,4 +1,4 @@
package opentimestamps
package verifyer
import (
"bytes"
@@ -8,6 +8,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"slices"
@@ -15,29 +16,38 @@ import (
"github.com/btcsuite/btcd/wire"
)
func NewEsploraClient(url string) Bitcoin {
if strings.HasSuffix(url, "/") {
url = url[0 : len(url)-1]
}
return esplora{url}
// esplora is a client for the Esplora API, which provides access to Bitcoin
// blockchain data.
type esplora struct {
httpClient *http.Client
baseURL string
}
type esplora struct{ baseurl string }
func NewEsploraClient(url string, timeout time.Duration) Bitcoin {
url = strings.TrimSuffix(url, "/")
h := &http.Client{
Timeout: timeout,
}
return esplora{h, url}
}
func (e esplora) GetBlockHash(height int64) (*chainhash.Hash, error) {
resp, err := http.Get(e.baseurl + "/block-height/" + strconv.FormatInt(height, 10))
resp, err := e.httpClient.Get(e.baseURL + "/block-height/" + strconv.FormatInt(height, 10))
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to get block hash: %w", err)
}
defer resp.Body.Close()
hexb, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to read block hash: %w", err)
}
hash, err := hex.DecodeString(string(hexb))
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to decode block hash: %w", err)
}
if len(hash) != chainhash.HashSize {
return nil, fmt.Errorf("got block hash (%x) of invalid size (expected %d)", hash, chainhash.HashSize)
@@ -50,11 +60,12 @@ func (e esplora) GetBlockHash(height int64) (*chainhash.Hash, error) {
}
func (e esplora) GetBlockHeader(hash *chainhash.Hash) (*wire.BlockHeader, error) {
resp, err := http.Get(fmt.Sprintf("%s/block/%s/header", e.baseurl, hash.String()))
resp, err := e.httpClient.Get(fmt.Sprintf("%s/block/%s/header", e.baseURL, hash.String()))
if err != nil {
return nil, err
}
defer resp.Body.Close()
hexb, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err