diff --git a/README.md b/README.md index dfb94ac..64b5258 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/example/main.go b/cmd/example/main.go new file mode 100644 index 0000000..0073eba --- /dev/null +++ b/cmd/example/main.go @@ -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)) +} diff --git a/cmd/ots/create.go b/cmd/ots/create.go new file mode 100644 index 0000000..f1a16dd --- /dev/null +++ b/cmd/ots/create.go @@ -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] ", + 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") +} diff --git a/cmd/ots/info.go b/cmd/ots/info.go new file mode 100644 index 0000000..faf2917 --- /dev/null +++ b/cmd/ots/info.go @@ -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] ", + 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) + } + } +} diff --git a/cmd/ots/main.go b/cmd/ots/main.go new file mode 100644 index 0000000..e61393f --- /dev/null +++ b/cmd/ots/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/cmd/ots/root.go b/cmd/ots/root.go new file mode 100644 index 0000000..f8f0d57 --- /dev/null +++ b/cmd/ots/root.go @@ -0,0 +1,64 @@ +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) +} + +// 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) +} diff --git a/cmd/ots/upgrade.go b/cmd/ots/upgrade.go new file mode 100644 index 0000000..6996b45 --- /dev/null +++ b/cmd/ots/upgrade.go @@ -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] ", + 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)) +} diff --git a/cmd/ots/verify.go b/cmd/ots/verify.go new file mode 100644 index 0000000..49ad737 --- /dev/null +++ b/cmd/ots/verify.go @@ -0,0 +1,155 @@ +package main + +import ( + "crypto/sha256" + "log/slog" + "os" + "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] ", + 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 ' 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() + slog.Info("Verification successful", + "block_height", att.BitcoinBlockHeight) + + 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") +} diff --git a/examples/bad-stamp.txt b/examples/bad-stamp.txt deleted file mode 100644 index 82d7cfe..0000000 --- a/examples/bad-stamp.txt +++ /dev/null @@ -1,2 +0,0 @@ -The timestamp on this file is well-formatted, but will fail Bitcoin block -header validation. diff --git a/examples/bad-stamp.txt.ots b/examples/bad-stamp.txt.ots deleted file mode 100644 index 897b46d..0000000 Binary files a/examples/bad-stamp.txt.ots and /dev/null differ diff --git a/examples/empty b/examples/empty deleted file mode 100644 index e69de29..0000000 diff --git a/examples/empty.ots b/examples/empty.ots deleted file mode 100644 index c4c3ea3..0000000 Binary files a/examples/empty.ots and /dev/null differ diff --git a/examples/hello-world.txt b/examples/hello-world.txt deleted file mode 100644 index 980a0d5..0000000 --- a/examples/hello-world.txt +++ /dev/null @@ -1 +0,0 @@ -Hello World! diff --git a/examples/hello-world.txt.ots b/examples/hello-world.txt.ots deleted file mode 100644 index d8357eb..0000000 Binary files a/examples/hello-world.txt.ots and /dev/null differ diff --git a/examples/incomplete.txt b/examples/incomplete.txt deleted file mode 100644 index e641709..0000000 --- a/examples/incomplete.txt +++ /dev/null @@ -1 +0,0 @@ -The timestamp on this file is incomplete, and can be upgraded. diff --git a/examples/incomplete.txt.ots b/examples/incomplete.txt.ots deleted file mode 100644 index f49cd35..0000000 Binary files a/examples/incomplete.txt.ots and /dev/null differ diff --git a/examples/known-and-unknown-notary.txt b/examples/known-and-unknown-notary.txt deleted file mode 100644 index 9de80cd..0000000 --- a/examples/known-and-unknown-notary.txt +++ /dev/null @@ -1,2 +0,0 @@ -This file's timestamp has two attestations, one from a known notary, and one -from an unknown notary. diff --git a/examples/known-and-unknown-notary.txt.ots b/examples/known-and-unknown-notary.txt.ots deleted file mode 100644 index 992093d..0000000 Binary files a/examples/known-and-unknown-notary.txt.ots and /dev/null differ diff --git a/examples/merkle1.txt b/examples/merkle1.txt deleted file mode 100644 index 5b74465..0000000 --- a/examples/merkle1.txt +++ /dev/null @@ -1,2 +0,0 @@ -This file is one of three different files that have been timestamped together -with a single merkle tree. (1/3) diff --git a/examples/merkle1.txt.ots b/examples/merkle1.txt.ots deleted file mode 100644 index 9c9ff83..0000000 Binary files a/examples/merkle1.txt.ots and /dev/null differ diff --git a/examples/merkle2.txt b/examples/merkle2.txt deleted file mode 100644 index a66a551..0000000 --- a/examples/merkle2.txt +++ /dev/null @@ -1,2 +0,0 @@ -This file is one of three different files that have been timestamped together -with a single merkle tree. (2/3) diff --git a/examples/merkle2.txt.ots b/examples/merkle2.txt.ots deleted file mode 100644 index 9cadc72..0000000 Binary files a/examples/merkle2.txt.ots and /dev/null differ diff --git a/examples/merkle3.txt b/examples/merkle3.txt deleted file mode 100644 index 2fd9fa0..0000000 --- a/examples/merkle3.txt +++ /dev/null @@ -1,2 +0,0 @@ -This file is one of three different files that have been timestamped together -with a single merkle tree. (3/3) diff --git a/examples/merkle3.txt.ots b/examples/merkle3.txt.ots deleted file mode 100644 index 6bb674e..0000000 Binary files a/examples/merkle3.txt.ots and /dev/null differ diff --git a/examples/two-calendars.txt b/examples/two-calendars.txt deleted file mode 100644 index 47d79f5..0000000 --- a/examples/two-calendars.txt +++ /dev/null @@ -1 +0,0 @@ -This file has an (incomplete) timestamp with two different calendars. diff --git a/examples/two-calendars.txt.ots b/examples/two-calendars.txt.ots deleted file mode 100644 index 1f3522c..0000000 Binary files a/examples/two-calendars.txt.ots and /dev/null differ diff --git a/examples/unknown-notary.txt b/examples/unknown-notary.txt deleted file mode 100644 index 42a7a43..0000000 --- a/examples/unknown-notary.txt +++ /dev/null @@ -1 +0,0 @@ -This file's timestamp has a single attestation from an unknown notary. diff --git a/examples/unknown-notary.txt.ots b/examples/unknown-notary.txt.ots deleted file mode 100644 index af149ef..0000000 Binary files a/examples/unknown-notary.txt.ots and /dev/null differ diff --git a/fixtures/flatearthers-united.txt b/fixtures/flatearthers-united.txt new file mode 100644 index 0000000..5892281 --- /dev/null +++ b/fixtures/flatearthers-united.txt @@ -0,0 +1 @@ +earth is flat \ No newline at end of file diff --git a/fixtures/flatearthers-united.txt.ots b/fixtures/flatearthers-united.txt.ots new file mode 100644 index 0000000..b2a3523 Binary files /dev/null and b/fixtures/flatearthers-united.txt.ots differ diff --git a/go.mod b/go.mod index 8ac1acf..3a1293c 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,27 @@ -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/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 ) diff --git a/go.sum b/go.sum index fa4156f..c72b310 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,25 @@ github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= 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 +30,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,10 +51,15 @@ 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= @@ -59,13 +75,26 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 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/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.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/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 +110,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 +124,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= diff --git a/ots/attestation.go b/ots/attestation.go new file mode 100644 index 0000000..caf576e --- /dev/null +++ b/ots/attestation.go @@ -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" + } +} diff --git a/unused.go b/ots/crypto.go similarity index 87% rename from unused.go rename to ots/crypto.go index 6118792..ad476a6 100644 --- a/unused.go +++ b/ots/crypto.go @@ -1,4 +1,4 @@ -package opentimestamps +package ots import ( deprecated_ripemd160 "golang.org/x/crypto/ripemd160" diff --git a/ots.go b/ots/file.go similarity index 55% rename from ots.go rename to ots/file.go index cf26bce..4aaafb4 100644 --- a/ots.go +++ b/ots/file.go @@ -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 } diff --git a/helpers.go b/ots/instruction.go similarity index 64% rename from helpers.go rename to ots/instruction.go index eb5dfc6..4e33a06 100644 --- a/helpers.go +++ b/ots/instruction.go @@ -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 ab. // 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 +} diff --git a/ots/operation.go b/ots/operation.go new file mode 100644 index 0000000..620e656 --- /dev/null +++ b/ots/operation.go @@ -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") }}, +} diff --git a/parsers.go b/ots/sequence.go similarity index 50% rename from parsers.go rename to ots/sequence.go index d144e4d..f0b2d3d 100644 --- a/parsers.go +++ b/ots/sequence.go @@ -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 -} diff --git a/stamp.go b/stamp.go index 5b690c0..77031d7 100644 --- a/stamp.go +++ b/stamp.go @@ -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 +} diff --git a/utils.go b/utils.go deleted file mode 100644 index 16744cf..0000000 --- a/utils.go +++ /dev/null @@ -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 -} diff --git a/varn/buffer.go b/varn/buffer.go new file mode 100644 index 0000000..1240cd5 --- /dev/null +++ b/varn/buffer.go @@ -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 +} diff --git a/varn/buffer_test.go b/varn/buffer_test.go new file mode 100644 index 0000000..742d3e6 --- /dev/null +++ b/varn/buffer_test.go @@ -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) +} diff --git a/varn/varn.go b/varn/varn.go new file mode 100644 index 0000000..d93991d --- /dev/null +++ b/varn/varn.go @@ -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 +} diff --git a/varn/varn_test.go b/varn/varn_test.go new file mode 100644 index 0000000..46ec785 --- /dev/null +++ b/varn/varn_test.go @@ -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) +} diff --git a/verifier.go b/verifier.go deleted file mode 100644 index 4449a76..0000000 --- a/verifier.go +++ /dev/null @@ -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 -} diff --git a/verifyer/bitcoin.go b/verifyer/bitcoin.go new file mode 100644 index 0000000..bc7342d --- /dev/null +++ b/verifyer/bitcoin.go @@ -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) diff --git a/bitcoind.go b/verifyer/bitcoind.go similarity index 87% rename from bitcoind.go rename to verifyer/bitcoind.go index a5f2134..d54217a 100644 --- a/bitcoind.go +++ b/verifyer/bitcoind.go @@ -1,4 +1,4 @@ -package opentimestamps +package verifyer import "github.com/btcsuite/btcd/rpcclient" diff --git a/esplora.go b/verifyer/esplora.go similarity index 60% rename from esplora.go rename to verifyer/esplora.go index 3b116a9..c76e9c4 100644 --- a/esplora.go +++ b/verifyer/esplora.go @@ -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