From bd65c6a4af1a864ddd279a8d2896f2e33fccd779 Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Fri, 10 Oct 2025 20:14:00 +0530 Subject: [PATCH 1/4] tle: add -m flag to show metadata; support INPUT parsing and timestamp estimation --- cmd/tle/commands/commands.go | 6 +- cmd/tle/commands/metadata.go | 146 +++++++++++++++++++++++++++++++++++ cmd/tle/tle.go | 6 +- 3 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 cmd/tle/commands/metadata.go diff --git a/cmd/tle/commands/commands.go b/cmd/tle/commands/commands.go index f64faf9..b20c8d8 100644 --- a/cmd/tle/commands/commands.go +++ b/cmd/tle/commands/commands.go @@ -59,7 +59,11 @@ Example: $ tle -D 10d -o encrypted_file data_to_encrypt After the specified duration: - $ tle -d -o decrypted_file.txt encrypted_file` + $ tle -d -o decrypted_file.txt encrypted_file + +Metadata examples: + $ tle -m # Prints network metadata (YAML) + $ tle -m encrypted.age # Prints ciphertext metadata (round, chain, time)` // PrintUsage displays the usage information. func PrintUsage(log *log.Logger) { diff --git a/cmd/tle/commands/metadata.go b/cmd/tle/commands/metadata.go new file mode 100644 index 0000000..7334c51 --- /dev/null +++ b/cmd/tle/commands/metadata.go @@ -0,0 +1,146 @@ +package commands + +import ( + "bufio" + "encoding/base64" + "fmt" + "io" + "strconv" + "strings" + "time" + + "filippo.io/age/armor" + "gopkg.in/yaml.v3" + + "github.com/drand/tlock/networks/http" +) + +type CiphertextMetadata struct { + Round uint64 `yaml:"round"` + ChainHash string `yaml:"chain_hash"` + Time time.Time `yaml:"time"` +} + +// Metadata reads INPUT from src and, if it contains a tlock stanza, outputs YAML with round, chainhash and estimated time. +func Metadata(dst io.Writer, src io.Reader, network *http.Network) error { + rr := bufio.NewReader(src) + // Only support armored input for metadata extraction in this change. + if start, _ := rr.Peek(len(armor.Header)); string(start) != armor.Header { + return fmt.Errorf("metadata from INPUT currently supports only armored age files") + } + // Read base64-encoded header block: try decoding each line until we find the tlock stanza. + if _, err := rr.ReadString('\n'); err != nil { // BEGIN line + return fmt.Errorf("read begin line: %w", err) + } + var round uint64 + var chainHash string + found := false + for { + line, err := rr.ReadString('\n') + if err != nil { + return fmt.Errorf("read armored content: %w", err) + } + s := strings.TrimSpace(line) + if s == "" { + continue + } + if strings.HasPrefix(s, "-----END ") { + break + } + // Try to decode this line as base64 + dec, err := base64.StdEncoding.DecodeString(s) + if err != nil { + continue + } + decoded := string(dec) + // Look for tlock stanza in this decoded line + for _, line := range strings.Split(decoded, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "-> ") { + fields := strings.Fields(line) + if len(fields) >= 4 && fields[1] == "tlock" { + r, err := strconv.ParseUint(fields[2], 10, 64) + if err != nil { + return fmt.Errorf("parse round: %w", err) + } + round = r + chainHash = fields[3] + found = true + break + } + } + } + if found { + break + } + } + if !found { + return fmt.Errorf("no tlock stanza found in armored age header") + } + + // Estimate time for the given round + now := time.Now() + current := network.Current(now) + low := now.Add(-365 * 24 * time.Hour) + high := now.Add(365 * 24 * time.Hour) + if round <= current { + high = now + low = now.Add(-365 * 24 * time.Hour) + } else { + low = now + high = now.Add(365 * 24 * time.Hour) + } + + t, err := roundToTimeBinarySearch(network, round, low, high) + if err != nil { + return fmt.Errorf("estimate time: %w", err) + } + + out := CiphertextMetadata{Round: round, ChainHash: chainHash, Time: t} + b, err := yaml.Marshal(out) + if err != nil { + return fmt.Errorf("yaml marshal: %w", err) + } + if _, err := dst.Write(b); err != nil { + return fmt.Errorf("write: %w", err) + } + return nil +} + +// roundToTimeBinarySearch searches for a time whose round is the target. +func roundToTimeBinarySearch(network *http.Network, target uint64, low, high time.Time) (time.Time, error) { + // If bounds are inverted, fix. + if high.Before(low) { + low, high = high, low + } + // Binary search with tolerance of 1 round. + for i := 0; i < 64; i++ { + mid := low.Add(high.Sub(low) / 2) + r := network.RoundNumber(mid) + if r == target { + return mid, nil + } + if r < target { + low = mid.Add(time.Second) + } else { + high = mid.Add(-time.Second) + } + if !high.After(low) { + break + } + } + // Best effort: return low as approximation. + return low, nil +} + +// parseArgs tries to extract round and chain from a tlock stanza arguments slice. +func parseArgs(args []string) (uint64, string, error) { + if len(args) != 2 { + return 0, "", fmt.Errorf("invalid tlock stanza arguments") + } + round, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + return 0, "", err + } + return round, args[1], nil +} diff --git a/cmd/tle/tle.go b/cmd/tle/tle.go index 0cd505c..49a12b0 100644 --- a/cmd/tle/tle.go +++ b/cmd/tle/tle.go @@ -72,7 +72,11 @@ func run() error { switch { case flags.Metadata: - err = tlock.New(network).Metadata(dst) + if name := flag.Arg(0); name != "" && name != "-" { + err = commands.Metadata(dst, src, network) + } else { + err = tlock.New(network).Metadata(dst) + } case flags.Decrypt: err = tlock.New(network).Decrypt(dst, src) default: From f767d601cb1647911e4c848fe644a8644f63787e Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Fri, 10 Oct 2025 21:05:50 +0530 Subject: [PATCH 2/4] fix: remove unused variables and function to satisfy staticcheck --- cmd/tle/commands/metadata.go | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/cmd/tle/commands/metadata.go b/cmd/tle/commands/metadata.go index 7334c51..f479676 100644 --- a/cmd/tle/commands/metadata.go +++ b/cmd/tle/commands/metadata.go @@ -81,8 +81,7 @@ func Metadata(dst io.Writer, src io.Reader, network *http.Network) error { // Estimate time for the given round now := time.Now() current := network.Current(now) - low := now.Add(-365 * 24 * time.Hour) - high := now.Add(365 * 24 * time.Hour) + var low, high time.Time if round <= current { high = now low = now.Add(-365 * 24 * time.Hour) @@ -134,13 +133,4 @@ func roundToTimeBinarySearch(network *http.Network, target uint64, low, high tim } // parseArgs tries to extract round and chain from a tlock stanza arguments slice. -func parseArgs(args []string) (uint64, string, error) { - if len(args) != 2 { - return 0, "", fmt.Errorf("invalid tlock stanza arguments") - } - round, err := strconv.ParseUint(args[0], 10, 64) - if err != nil { - return 0, "", err - } - return round, args[1], nil -} +// (no other helpers) From 968302df0544dc39116629fc2e92713a611582f5 Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Sun, 16 Nov 2025 13:29:31 +0530 Subject: [PATCH 3/4] refactor: use armor.NewReader directly instead of manual armor parsing --- cmd/tle/commands/metadata.go | 67 +++++++++++++----------------------- 1 file changed, 24 insertions(+), 43 deletions(-) diff --git a/cmd/tle/commands/metadata.go b/cmd/tle/commands/metadata.go index f479676..33f004f 100644 --- a/cmd/tle/commands/metadata.go +++ b/cmd/tle/commands/metadata.go @@ -2,7 +2,6 @@ package commands import ( "bufio" - "encoding/base64" "fmt" "io" "strconv" @@ -24,56 +23,38 @@ type CiphertextMetadata struct { // Metadata reads INPUT from src and, if it contains a tlock stanza, outputs YAML with round, chainhash and estimated time. func Metadata(dst io.Writer, src io.Reader, network *http.Network) error { rr := bufio.NewReader(src) + + // Use armor.NewReader to handle armor decoding automatically // Only support armored input for metadata extraction in this change. - if start, _ := rr.Peek(len(armor.Header)); string(start) != armor.Header { - return fmt.Errorf("metadata from INPUT currently supports only armored age files") - } - // Read base64-encoded header block: try decoding each line until we find the tlock stanza. - if _, err := rr.ReadString('\n'); err != nil { // BEGIN line - return fmt.Errorf("read begin line: %w", err) - } + armorReader := armor.NewReader(rr) + + // Read from the de-armored content to find the tlock stanza + scanner := bufio.NewScanner(armorReader) var round uint64 var chainHash string found := false - for { - line, err := rr.ReadString('\n') - if err != nil { - return fmt.Errorf("read armored content: %w", err) - } - s := strings.TrimSpace(line) - if s == "" { - continue - } - if strings.HasPrefix(s, "-----END ") { - break - } - // Try to decode this line as base64 - dec, err := base64.StdEncoding.DecodeString(s) - if err != nil { - continue - } - decoded := string(dec) - // Look for tlock stanza in this decoded line - for _, line := range strings.Split(decoded, "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "-> ") { - fields := strings.Fields(line) - if len(fields) >= 4 && fields[1] == "tlock" { - r, err := strconv.ParseUint(fields[2], 10, 64) - if err != nil { - return fmt.Errorf("parse round: %w", err) - } - round = r - chainHash = fields[3] - found = true - break + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "-> ") { + fields := strings.Fields(line) + if len(fields) >= 4 && fields[1] == "tlock" { + r, err := strconv.ParseUint(fields[2], 10, 64) + if err != nil { + return fmt.Errorf("parse round: %w", err) } + round = r + chainHash = fields[3] + found = true + break } } - if found { - break - } } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("read armored content: %w", err) + } + if !found { return fmt.Errorf("no tlock stanza found in armored age header") } From 830c1e204ce85bd1354b5089b526a868bd24f074 Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Thu, 20 Nov 2025 02:08:47 +0530 Subject: [PATCH 4/4] test: add TestMetadata to validate metadata extraction from TLE files --- cmd/tle/commands/commands_test.go | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/cmd/tle/commands/commands_test.go b/cmd/tle/commands/commands_test.go index 273d612..436870b 100644 --- a/cmd/tle/commands/commands_test.go +++ b/cmd/tle/commands/commands_test.go @@ -3,10 +3,13 @@ package commands import ( "bytes" "os" + "path/filepath" "testing" "time" + "github.com/drand/tlock/networks/http" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) func TestParseDuration(t *testing.T) { @@ -148,3 +151,44 @@ func TestEncryptionWithDurationOverflowUsingOtherUnits(t *testing.T) { err := Encrypt(flags, os.Stdout, bytes.NewBufferString("very nice"), nil) require.ErrorIs(t, err, ErrInvalidDurationValue) } + +func TestMetadata(t *testing.T) { + if testing.Short() { + t.Skip("skipping network test in short mode") + } + + // use testnet quicknet-t network matching the testdata file + testnetHost := "http://pl-eu.testnet.drand.sh" + testnetQuicknetT := "cc9c398442737cbd141526600919edd69f1d6f9b4adb67e4d912fbc64341a9a5" + + network, err := http.NewNetwork(testnetHost, testnetQuicknetT) + require.NoError(t, err) + + // open the testdata file + testdataPath := filepath.Join("..", "..", "..", "testdata", "lorem-tle-testnet-quicknet-t-2024-01-17-15-28.tle") + f, err := os.Open(testdataPath) + require.NoError(t, err) + defer f.Close() + + // extract metadata + var output bytes.Buffer + err = Metadata(&output, f, network) + require.NoError(t, err) + + // verify output is valid YAML with expected fields + var metadata CiphertextMetadata + err = yaml.Unmarshal(output.Bytes(), &metadata) + require.NoError(t, err) + + // verify fields are populated + require.NotZero(t, metadata.Round, "round should be non-zero") + require.Equal(t, testnetQuicknetT, metadata.ChainHash, "chain hash should match") + require.False(t, metadata.Time.IsZero(), "time should be set") + + // verify the output contains the expected YAML keys + outputStr := output.String() + require.Contains(t, outputStr, "round:", "output should contain round field") + require.Contains(t, outputStr, "chain_hash:", "output should contain chain_hash field") + require.Contains(t, outputStr, "time:", "output should contain time field") + require.Contains(t, outputStr, testnetQuicknetT, "output should contain the chain hash value") +}