From 94bd7f927983119d91cbf8fd5c2bb754b303a02d Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Tue, 6 Oct 2026 12:53:43 +0000 Subject: [PATCH 1/3] tufrepo/tx: add RequireTimestampVersion `TxnCommit` only detects a racing writer that committed after `TxnStart`. Callers which compute new role data from an earlier read of the repository, such as merging the previous targets, have no way to tell that the repository moved on in between, and silently drop the other writer's changes. Add a `TxnOp` which fails with `ErrClobberedTransaction` unless the transaction starts from a given timestamp version. Timestamp versions identify a repository state, as `TxnCommit` never overwrites a versioned timestamp file. Signed-off-by: Martin Pitt --- internal/tufrepo/tx.go | 27 ++++++++++++++++++ internal/tufrepo/tx_test.go | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/internal/tufrepo/tx.go b/internal/tufrepo/tx.go index 2dace2e..df44743 100644 --- a/internal/tufrepo/tx.go +++ b/internal/tufrepo/tx.go @@ -554,6 +554,33 @@ func InitTxn(initRoot *tufext.SignedRoot) *Transaction { // since [Repository.TxnStart]. var ErrClobberedTransaction = errors.New("transaction rejected because repository state has changed") +// RequireTimestampVersion returns a [TxnOp] that fails with +// [ErrClobberedTransaction] unless the transaction started from the timestamp +// with the given version, or from a repository without a timestamp if version +// is 0. +// +// [Repository.TxnCommit] only detects changes made after [Repository.TxnStart]. +// A caller which computed the new role data from an earlier read of the +// repository (such as merging the previous targets) uses this to extend that +// check back to its read: timestamp versions are never reused, as +// [Repository.TxnCommit] writes each one without clobbering. +func RequireTimestampVersion(version int64) TxnOp { + return NewTxnOp( + fmt.Sprintf("require timestamp version %d", version), + func(_ context.Context, tx *Transaction) error { + var current int64 + if tx.timestamp != nil { + current = tx.timestamp.Signed.Version + } + if current != version { + return fmt.Errorf("%w: expected timestamp version %d, found %d", + ErrClobberedTransaction, version, current) + } + return nil + }, + ) +} + // ErrInvalidTransactionState is returned from [Repository.TxnCommit] if a // transaction has an internally invalid state that means it cannot committed // to a repository. diff --git a/internal/tufrepo/tx_test.go b/internal/tufrepo/tx_test.go index a4c2008..1734012 100644 --- a/internal/tufrepo/tx_test.go +++ b/internal/tufrepo/tx_test.go @@ -924,3 +924,58 @@ func TestTxnCommit_Twice_DoesNotDestroyFirstCommit(t *testing.T) { assert.Equal(t, firstSnap.Version, liveSnap.Version) assert.Equal(t, firstSnap.Hashes, liveSnap.Hashes) } + +// ----- RequireTimestampVersion ------------------------------------------- + +// commitTarget publishes one target in its own transaction, as another +// publisher would. +func commitTarget(ctx context.Context, t *testing.T, bs *bootstrap, path string) *tufext.SignedTimestamp { + t.Helper() + tx, err := bs.repo.TxnStart(ctx) + require.NoError(t, err) + require.NoError(t, tx.Apply(ctx, addTargetOp(path, 1))) + _, err = tx.Sign(ctx, bs.store) + require.NoError(t, err) + ts, err := bs.repo.TxnCommit(ctx, tx) + require.NoError(t, err) + return ts +} + +func TestRequireTimestampVersion_Current(t *testing.T) { + ctx := context.Background() + bs := bootstrapRepo(t) + base := currentTimestamp(ctx, t, bs.repo).Signed.Version + + tx, err := bs.repo.TxnStart(ctx) + require.NoError(t, err) + require.NoError(t, tx.Apply(ctx, tufrepo.RequireTimestampVersion(base))) +} + +// A publisher computes its new targets from the repository it read, and only +// starts the transaction afterwards. Another publisher committing in between is +// invisible to TxnCommit's check, so the stale base has to be caught here, or +// the other publisher's targets are silently dropped. +func TestRequireTimestampVersion_ChangedBeforeTxnStart(t *testing.T) { + ctx := context.Background() + bs := bootstrapRepo(t) + base := currentTimestamp(ctx, t, bs.repo).Signed.Version + + racing := commitTarget(ctx, t, bs, "t/racing.bin") + require.NotEqual(t, base, racing.Signed.Version) + + tx, err := bs.repo.TxnStart(ctx) + require.NoError(t, err) + err = tx.Apply(ctx, tufrepo.RequireTimestampVersion(base)) + require.ErrorIs(t, err, tufrepo.ErrClobberedTransaction) + assert.ErrorContains(t, err, fmt.Sprintf("expected timestamp version %d, found %d", base, racing.Signed.Version)) +} + +func TestRequireTimestampVersion_ZeroRejectsExistingTimestamp(t *testing.T) { + ctx := context.Background() + bs := bootstrapRepo(t) + + tx, err := bs.repo.TxnStart(ctx) + require.NoError(t, err) + err = tx.Apply(ctx, tufrepo.RequireTimestampVersion(0)) + require.ErrorIs(t, err, tufrepo.ErrClobberedTransaction) +} From fe06e5586ca5eb012ae0fd783f80db2039556721 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Tue, 6 Oct 2026 12:53:43 +0000 Subject: [PATCH 2/3] hardhat: repoctl snapshot: add --if-timestamp-version Expose `RequireTimestampVersion` so that scripts which compute targets before calling `repoctl snapshot` can refuse to commit over a repository state they did not compute them from. Signed-off-by: Martin Pitt --- cmd/hardhat/repoctl.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmd/hardhat/repoctl.go b/cmd/hardhat/repoctl.go index 48a3c47..a2296b9 100644 --- a/cmd/hardhat/repoctl.go +++ b/cmd/hardhat/repoctl.go @@ -237,6 +237,12 @@ var repoctlRefreshCommand = withTxExpireAfterFlag(&cli.Command{ var repoctlSnapshotCommand = withTxExpireAfterFlag(&cli.Command{ Name: "snapshot", Usage: "update the repo's snapshot.json", + Flags: []cli.Flag{ + &cli.Int64Flag{ + Name: "if-timestamp-version", + Usage: "fail unless the repository is still at this timestamp version (0: has no timestamp yet), i.e. the one the targets were computed from", + }, + }, Arguments: []cli.Argument{ &cli.StringArgs{ Name: "targets", @@ -255,6 +261,12 @@ var repoctlSnapshotCommand = withTxExpireAfterFlag(&cli.Command{ } tx.RefTime, _ = ctxext.RefTime(ctx) + if cmd.IsSet("if-timestamp-version") { + if err := tx.Apply(ctx, tufrepo.RequireTimestampVersion(cmd.Int64("if-timestamp-version"))); err != nil { + return err + } + } + if err := applyTxExpireAfter(ctx, tx); err != nil { return err } From 3c49547ed1323a850e445aab2a8015d49bebce40 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Tue, 6 Oct 2026 12:53:43 +0000 Subject: [PATCH 3/3] hack: repo-publish: refuse to commit over a racing publish With `--merge`, repo-publish.sh reads the current targets from `timestamp.json`, merges them into the new targets, and only then runs `repoctl snapshot`. That starts the transaction and takes the timestamp ETag that `TxnCommit` compares against. A publish which commits between the merge and the snapshot is not detected: the snapshot commits successfully (based on the newer timestamp), but with targets merged from the older one, and the other publisher's targets are gone. `--no-merge` has the same problem whenever the given targets were derived from the repository's current state. Read `timestamp.json` once, merge from that copy, and pass its version to `repoctl snapshot --if-timestamp-version`, so that the snapshot fails instead. Signed-off-by: Martin Pitt --- hack/repo-publish.sh | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/hack/repo-publish.sh b/hack/repo-publish.sh index b02ad69..56b1618 100755 --- a/hack/repo-publish.sh +++ b/hack/repo-publish.sh @@ -245,12 +245,22 @@ read -ra builder_keyids <<<"$(jq -rM '.signed.roles.targets.keyids[]' <"$root_js builder_sign_flags=("${builder_keyids[@]/#/--keyid=}") hardhat_targets_flags+=("${builder_sign_flags[@]}") +# The repository state everything below is computed from. Another publisher may +# commit before we do; snapshot then refuses to commit instead of silently +# dropping what they published. Only timestamp.json is rewritten in place, so +# read it just once; the versioned files it leads to never change. +base_timestamp_version=0 +if [ -f "$REPO_DIR/timestamp.json" ]; then + base_timestamp_json="$(mktempfile timestamp.json)" + cp "$REPO_DIR/timestamp.json" "$base_timestamp_json" + base_timestamp_version="$(jq -rM '.signed.version' <"$base_timestamp_json")" +fi + if [ -n "$merge_old_targets" ]; then # Figure out the latest targets.json. - latest_timestamp_json="$REPO_DIR/timestamp.json" # If the repository was just initialised, we skip this. - if [ -f "$latest_timestamp_json" ]; then - latest_snapshot_json="$REPO_DIR/$(jq -rM '.signed.meta["snapshot.json"].version' <"$latest_timestamp_json").snapshot.json" + if [ "$base_timestamp_version" != 0 ]; then + latest_snapshot_json="$REPO_DIR/$(jq -rM '.signed.meta["snapshot.json"].version' <"$base_timestamp_json").snapshot.json" latest_targets_json="$REPO_DIR/$(jq -rM '.signed.meta["targets.json"].version' <"$latest_snapshot_json").targets.json" hardhat_targets_flags+=("--include-from=$latest_targets_json") else @@ -310,4 +320,5 @@ PUBLISHER_KEYSTORE="$KEY_DIR/quarryd-keys" hardhat repoctl snapshot \ --keystore="$PUBLISHER_KEYSTORE" \ --repo-metadir="$REPO_DIR" \ + --if-timestamp-version="$base_timestamp_version" \ targets="$targets_json"