This commit is contained in:
2025-04-11 13:31:02 +02:00
parent ba1196a962
commit 03f30f1968
48 changed files with 1685 additions and 474 deletions

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)
}
}

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

@@ -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)
}

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))
}

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

@@ -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] <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()
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")
}