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/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") +} diff --git a/cmd/tle/commands/metadata.go b/cmd/tle/commands/metadata.go new file mode 100644 index 0000000..33f004f --- /dev/null +++ b/cmd/tle/commands/metadata.go @@ -0,0 +1,117 @@ +package commands + +import ( + "bufio" + "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) + + // Use armor.NewReader to handle armor decoding automatically + // Only support armored input for metadata extraction in this change. + 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 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 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") + } + + // Estimate time for the given round + now := time.Now() + current := network.Current(now) + var low, high time.Time + 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. +// (no other helpers) 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: