CVE-2026-44544
Description
gittuf is a platform-agnostic Git security system. Prior to 0.14.0, an attacker with push access to gittuf's Reference State Log (RSL) can roll back the current policy to any previous policy trusted by the current set of root keys. gittuf determines the policy to load by inspecting the RSL. Except for the very first policy (which is automatically trusted given gittuf's TOFU model, or verified against manually specified keys), whenever an RSL entry that points to a new policy is encountered, gittuf validates that this policy is trusted. This is done by checking that the new policy’s root metadata is signed by the required threshold of the current policy's root keys. Because of this, an attacker with push access to the RSL may create a new entry that references an old policy (that is trusted by the most recent policy's set of root keys), thereby rolling back gittuf's policy to the attacker's chosen state. This vulnerability is fixed in 0.14.0.
Affected products
1Patches
1dd76efa505f9Merge pull request #1319 from gittuf/vuln-fixes
26 files changed · +961 −326
docs/cli/gittuf_policy_increment-version.md+35 −0 added@@ -0,0 +1,35 @@ +## gittuf policy increment-version + +Increment the integer version of the specified rule file metadata + +### Synopsis + +The 'increment-version' command increments the integer version of the specified rule file metadata without making any other changes. + +``` +gittuf policy increment-version [flags] +``` + +### Options + +``` + -h, --help help for increment-version + --policy-name string name of policy file to increment version of (default "targets") +``` + +### Options inherited from parent commands + +``` + --create-rsl-entry create RSL entry for policy change immediately (note: the RSL will not be synced with the remote) + --no-color turn off colored output + --profile enable CPU and memory profiling + --profile-CPU-file string file to store CPU profile (default "cpu.prof") + --profile-memory-file string file to store memory profile (default "memory.prof") + -k, --signing-key string signing key to use to sign root of trust (path to SSH key, "fulcio:" for Sigstore) + --verbose enable verbose logging +``` + +### SEE ALSO + +* [gittuf policy](gittuf_policy.md) - Tools to manage gittuf policies +
docs/cli/gittuf_policy.md+1 −0 modified@@ -32,6 +32,7 @@ The 'policy' command provides a suite of tools for managing gittuf policy config * [gittuf policy add-rule](gittuf_policy_add-rule.md) - Add a new rule to a policy file * [gittuf policy apply](gittuf_policy_apply.md) - Validate and apply changes from policy-staging to policy * [gittuf policy discard](gittuf_policy_discard.md) - Discard the currently staged changes to policy +* [gittuf policy increment-version](gittuf_policy_increment-version.md) - Increment the integer version of the specified rule file metadata * [gittuf policy init](gittuf_policy_init.md) - Initialize policy file * [gittuf policy list-principals](gittuf_policy_list-principals.md) - List principals for the current policy in the specified rule file * [gittuf policy list-rules](gittuf_policy_list-rules.md) - List rules for the current state
docs/cli/gittuf_trust_increment-version.md+34 −0 added@@ -0,0 +1,34 @@ +## gittuf trust increment-version + +Increment the integer version of the root metadata + +### Synopsis + +The 'increment-version' command increments the integer version of the root metadata without making any other changes. + +``` +gittuf trust increment-version [flags] +``` + +### Options + +``` + -h, --help help for increment-version +``` + +### Options inherited from parent commands + +``` + --create-rsl-entry create RSL entry for policy change immediately (note: the RSL will not be synced with the remote) + --no-color turn off colored output + --profile enable CPU and memory profiling + --profile-CPU-file string file to store CPU profile (default "cpu.prof") + --profile-memory-file string file to store memory profile (default "memory.prof") + -k, --signing-key string signing key to use to sign root of trust (path to SSH key, "fulcio:" for Sigstore) + --verbose enable verbose logging +``` + +### SEE ALSO + +* [gittuf trust](gittuf_trust.md) - Tools for gittuf's root of trust +
docs/cli/gittuf_trust.md+1 −0 modified@@ -38,6 +38,7 @@ The 'trust' command provides tools to manage gittuf's root of trust, including s * [gittuf trust apply](gittuf_trust_apply.md) - Validate and apply changes from policy-staging to policy * [gittuf trust disable-github-app-approvals](gittuf_trust_disable-github-app-approvals.md) - Mark GitHub app approvals as untrusted henceforth * [gittuf trust enable-github-app-approvals](gittuf_trust_enable-github-app-approvals.md) - Mark GitHub app approvals as trusted henceforth +* [gittuf trust increment-version](gittuf_trust_increment-version.md) - Increment the integer version of the root metadata * [gittuf trust init](gittuf_trust_init.md) - Initialize gittuf root of trust for repository * [gittuf trust inspect-root](gittuf_trust_inspect-root.md) - Inspect root metadata * [gittuf trust list-global-rules](gittuf_trust_list-global-rules.md) - List global rules for the current state
experimental/gittuf/root.go+38 −7 modified@@ -6,9 +6,7 @@ package gittuf import ( "context" "crypto/sha256" - "encoding/base64" "encoding/hex" - "encoding/json" "errors" "fmt" "log/slog" @@ -900,6 +898,41 @@ func (r *Repository) UpdatePropagationDirective(ctx context.Context, signer ssli return r.updateRootMetadata(ctx, state, signer, rootMetadata, commitMessage, options.CreateRSLEntry, signCommit) } +func (r *Repository) IncrementRootVersion(ctx context.Context, signer sslibdsse.SignerVerifier, signCommit bool, opts ...trustpolicyopts.Option) error { + if signCommit { + slog.Debug("Checking if Git signing is configured...") + err := r.r.CanSign() + if err != nil { + return err + } + } + + options := &trustpolicyopts.Options{} + for _, fn := range opts { + fn(options) + } + + rootKeyID, err := signer.KeyID() + if err != nil { + return err + } + + slog.Debug("Loading current policy...") + state, err := policy.LoadCurrentState(ctx, r.r, policy.PolicyStagingRef) + if err != nil { + return err + } + + rootMetadata, err := r.loadRootMetadata(state, rootKeyID) + if err != nil { + return err + } + + // Just pass it to updateRootMetadata as it will increment the version + commitMessage := "Increment root version" + return r.updateRootMetadata(ctx, state, signer, rootMetadata, commitMessage, options.CreateRSLEntry, signCommit) +} + func (r *Repository) RemovePropagationDirective(ctx context.Context, signer sslibdsse.SignerVerifier, name string, signCommit bool, opts ...trustpolicyopts.Option) error { if signCommit { slog.Debug("Checking if Git signing is configured...") @@ -1388,15 +1421,13 @@ func (r *Repository) loadRootMetadata(state *policy.State, keyID string) (tuf.Ro } func (r *Repository) updateRootMetadata(ctx context.Context, state *policy.State, signer sslibdsse.SignerVerifier, rootMetadata tuf.RootMetadata, commitMessage string, createRSLEntry, signCommit bool) error { - rootMetadataBytes, err := json.Marshal(rootMetadata) + rootMetadata.IncrementVersion() + + env, err := dsse.CreateEnvelope(rootMetadata) if err != nil { return err } - env := state.Metadata.RootEnvelope - env.Signatures = []sslibdsse.Signature{} - env.Payload = base64.StdEncoding.EncodeToString(rootMetadataBytes) - slog.Debug("Signing updated root metadata...") env, err = dsse.SignEnvelope(ctx, env, signer) if err != nil {
experimental/gittuf/root_test.go+32 −0 modified@@ -2520,6 +2520,38 @@ func TestUpdateHook(t *testing.T) { }) } +func TestIncrementRootVersion(t *testing.T) { + r := createTestRepositoryWithRoot(t, "") + + state, err := policy.LoadCurrentState(testCtx, r.r, policy.PolicyRef) + require.Nil(t, err) + + oldRootMetadata, err := state.GetRootMetadata(false) + require.Nil(t, err) + + rootSigner := setupSSHKeysForSigning(t, rootKeyBytes, rootPubKeyBytes) + + err = r.IncrementRootVersion(testCtx, rootSigner, false) + require.Nil(t, err) + + err = r.StagePolicy(testCtx, "", true, false) + require.Nil(t, err) + + state, err = policy.LoadCurrentState(testCtx, r.r, policy.PolicyStagingRef) + if err != nil { + t.Fatal(err) + } + + newRootMetadata, err := state.GetRootMetadata(false) + require.Nil(t, err) + + assert.Equal(t, oldRootMetadata.GetVersion()+1, newRootMetadata.GetVersion()) + + // Check that the metadata is the same except for the version number + newRootMetadata.(*tufv02.RootMetadata).Version = oldRootMetadata.GetVersion() + assert.Equal(t, oldRootMetadata, newRootMetadata) +} + func TestListPropagationDirectives(t *testing.T) { t.Run("list propagation directives after add and remove", func(t *testing.T) { r := createTestRepositoryWithRoot(t, "")
experimental/gittuf/targets.go+66 −181 modified@@ -40,11 +40,6 @@ func (r *Repository) InitializeTargets(ctx context.Context, signer sslibdsse.Sig fn(options) } - keyID, err := signer.KeyID() - if err != nil { - return err - } - slog.Debug("Loading current policy...") state, err := policy.LoadCurrentState(ctx, r.r, policy.PolicyStagingRef, policyopts.BypassRSL()) if err != nil { @@ -67,7 +62,7 @@ func (r *Repository) InitializeTargets(ctx context.Context, signer sslibdsse.Sig return err } - slog.Debug(fmt.Sprintf("Signing initial rule file using '%s'...", keyID)) + slog.Debug("Signing initial rule file...") env, err = dsse.SignEnvelope(ctx, env, signer) if err != nil { return err @@ -108,11 +103,6 @@ func (r *Repository) AddDelegation(ctx context.Context, signer sslibdsse.SignerV fn(options) } - keyID, err := signer.KeyID() - if err != nil { - return err - } - slog.Debug("Loading current policy...") state, err := policy.LoadCurrentState(ctx, r.r, policy.PolicyStagingRef, policyopts.BypassRSL()) if err != nil { @@ -144,27 +134,8 @@ func (r *Repository) AddDelegation(ctx context.Context, signer sslibdsse.SignerV return err } - env, err := dsse.CreateEnvelope(targetsMetadata) - if err != nil { - return err - } - - slog.Debug(fmt.Sprintf("Signing updated rule file using '%s'...", keyID)) - env, err = dsse.SignEnvelope(ctx, env, signer) - if err != nil { - return err - } - - if targetsRoleName == policy.TargetsRoleName { - state.Metadata.TargetsEnvelope = env - } else { - state.Metadata.DelegationEnvelopes[targetsRoleName] = env - } - commitMessage := fmt.Sprintf("Add rule '%s' to policy '%s'", ruleName, targetsRoleName) - - slog.Debug("Committing policy...") - return state.Commit(r.r, commitMessage, options.CreateRSLEntry, signCommit) + return r.updateTargetsMetadata(ctx, state, signer, targetsRoleName, targetsMetadata, commitMessage, options.CreateRSLEntry, signCommit) } // UpdateDelegation is the interface for the user to update a rule to gittuf @@ -187,11 +158,6 @@ func (r *Repository) UpdateDelegation(ctx context.Context, signer sslibdsse.Sign fn(options) } - keyID, err := signer.KeyID() - if err != nil { - return err - } - slog.Debug("Loading current policy...") state, err := policy.LoadCurrentState(ctx, r.r, policy.PolicyStagingRef, policyopts.BypassRSL()) if err != nil { @@ -218,27 +184,8 @@ func (r *Repository) UpdateDelegation(ctx context.Context, signer sslibdsse.Sign return err } - env, err := dsse.CreateEnvelope(targetsMetadata) - if err != nil { - return err - } - - slog.Debug(fmt.Sprintf("Signing updated rule file using '%s'...", keyID)) - env, err = dsse.SignEnvelope(ctx, env, signer) - if err != nil { - return err - } - - if targetsRoleName == policy.TargetsRoleName { - state.Metadata.TargetsEnvelope = env - } else { - state.Metadata.DelegationEnvelopes[targetsRoleName] = env - } - commitMessage := fmt.Sprintf("Update rule '%s' in policy '%s'", ruleName, targetsRoleName) - - slog.Debug("Committing policy...") - return state.Commit(r.r, commitMessage, options.CreateRSLEntry, signCommit) + return r.updateTargetsMetadata(ctx, state, signer, targetsRoleName, targetsMetadata, commitMessage, options.CreateRSLEntry, signCommit) } // ReorderDelegations is the interface for the user to reorder rules in gittuf @@ -257,11 +204,6 @@ func (r *Repository) ReorderDelegations(ctx context.Context, signer sslibdsse.Si fn(options) } - keyID, err := signer.KeyID() - if err != nil { - return nil - } - slog.Debug("Loading current policy...") state, err := policy.LoadCurrentState(ctx, r.r, policy.PolicyStagingRef, policyopts.BypassRSL()) if err != nil { @@ -283,27 +225,8 @@ func (r *Repository) ReorderDelegations(ctx context.Context, signer sslibdsse.Si return err } - env, err := dsse.CreateEnvelope(targetsMetadata) - if err != nil { - return err - } - - slog.Debug(fmt.Sprintf("Signing updated rule file using '%s'...", keyID)) - env, err = dsse.SignEnvelope(ctx, env, signer) - if err != nil { - return err - } - - if targetsRoleName == policy.TargetsRoleName { - state.Metadata.TargetsEnvelope = env - } else { - state.Metadata.DelegationEnvelopes[targetsRoleName] = env - } - commitMessage := fmt.Sprintf("Reorder rules in policy '%s'", targetsRoleName) - - slog.Debug("Committing policy...") - return state.Commit(r.r, commitMessage, options.CreateRSLEntry, signCommit) + return r.updateTargetsMetadata(ctx, state, signer, targetsRoleName, targetsMetadata, commitMessage, options.CreateRSLEntry, signCommit) } // RemoveDelegation is the interface for a user to remove a rule from gittuf @@ -322,11 +245,6 @@ func (r *Repository) RemoveDelegation(ctx context.Context, signer sslibdsse.Sign fn(options) } - keyID, err := signer.KeyID() - if err != nil { - return err - } - slog.Debug("Loading current policy...") state, err := policy.LoadCurrentState(ctx, r.r, policy.PolicyStagingRef, policyopts.BypassRSL()) if err != nil { @@ -353,27 +271,8 @@ func (r *Repository) RemoveDelegation(ctx context.Context, signer sslibdsse.Sign return err } - env, err := dsse.CreateEnvelope(targetsMetadata) - if err != nil { - return err - } - - slog.Debug(fmt.Sprintf("Signing updated rule file using '%s'...", keyID)) - env, err = dsse.SignEnvelope(ctx, env, signer) - if err != nil { - return err - } - - if targetsRoleName == policy.TargetsRoleName { - state.Metadata.TargetsEnvelope = env - } else { - state.Metadata.DelegationEnvelopes[targetsRoleName] = env - } - commitMessage := fmt.Sprintf("Remove rule '%s' from policy '%s'", ruleName, targetsRoleName) - - slog.Debug("Committing policy...") - return state.Commit(r.r, commitMessage, options.CreateRSLEntry, signCommit) + return r.updateTargetsMetadata(ctx, state, signer, targetsRoleName, targetsMetadata, commitMessage, options.CreateRSLEntry, signCommit) } // AddPrincipalToTargets is the interface for a user to add a trusted principal @@ -392,11 +291,6 @@ func (r *Repository) AddPrincipalToTargets(ctx context.Context, signer sslibdsse fn(options) } - keyID, err := signer.KeyID() - if err != nil { - return err - } - slog.Debug("Loading current policy...") state, err := policy.LoadCurrentState(ctx, r.r, policy.PolicyStagingRef, policyopts.BypassRSL()) if err != nil { @@ -429,27 +323,8 @@ func (r *Repository) AddPrincipalToTargets(ctx context.Context, signer sslibdsse } } - env, err := dsse.CreateEnvelope(targetsMetadata) - if err != nil { - return err - } - - slog.Debug(fmt.Sprintf("Signing updated rule file using '%s'...", keyID)) - env, err = dsse.SignEnvelope(ctx, env, signer) - if err != nil { - return err - } - - if targetsRoleName == policy.TargetsRoleName { - state.Metadata.TargetsEnvelope = env - } else { - state.Metadata.DelegationEnvelopes[targetsRoleName] = env - } - commitMessage := fmt.Sprintf("Add principals to policy '%s'\n%s", targetsRoleName, principalIDs) - - slog.Debug("Committing policy...") - return state.Commit(r.r, commitMessage, options.CreateRSLEntry, signCommit) + return r.updateTargetsMetadata(ctx, state, signer, targetsRoleName, targetsMetadata, commitMessage, options.CreateRSLEntry, signCommit) } // UpdatePrincipalInTargets is the interface for a user to update a principal's @@ -468,11 +343,6 @@ func (r *Repository) UpdatePrincipalInTargets(ctx context.Context, signer sslibd fn(options) } - keyID, err := signer.KeyID() - if err != nil { - return err - } - slog.Debug("Loading current policy...") state, err := policy.LoadCurrentState(ctx, r.r, policy.PolicyStagingRef, policyopts.BypassRSL()) if err != nil { @@ -493,27 +363,8 @@ func (r *Repository) UpdatePrincipalInTargets(ctx context.Context, signer sslibd return err } - env, err := dsse.CreateEnvelope(targetsMetadata) - if err != nil { - return err - } - - slog.Debug(fmt.Sprintf("Signing updated rule file using '%s'...", keyID)) - env, err = dsse.SignEnvelope(ctx, env, signer) - if err != nil { - return err - } - - if targetsRoleName == policy.TargetsRoleName { - state.Metadata.TargetsEnvelope = env - } else { - state.Metadata.DelegationEnvelopes[targetsRoleName] = env - } - commitMessage := fmt.Sprintf("Update principal '%s' in policy '%s'", principal.ID(), targetsRoleName) - - slog.Debug("Committing policy...") - return state.Commit(r.r, commitMessage, options.CreateRSLEntry, signCommit) + return r.updateTargetsMetadata(ctx, state, signer, targetsRoleName, targetsMetadata, commitMessage, options.CreateRSLEntry, signCommit) } // RemovePrincipalFromTargets is the interface for a user to remove a principal @@ -532,11 +383,6 @@ func (r *Repository) RemovePrincipalFromTargets(ctx context.Context, signer ssli fn(options) } - keyID, err := signer.KeyID() - if err != nil { - return err - } - slog.Debug("Loading current policy...") state, err := policy.LoadCurrentState(ctx, r.r, policy.PolicyStagingRef, policyopts.BypassRSL()) if err != nil { @@ -557,27 +403,8 @@ func (r *Repository) RemovePrincipalFromTargets(ctx context.Context, signer ssli return err } - env, err := dsse.CreateEnvelope(targetsMetadata) - if err != nil { - return err - } - - slog.Debug(fmt.Sprintf("Signing updated rule file using '%s'...", keyID)) - env, err = dsse.SignEnvelope(ctx, env, signer) - if err != nil { - return err - } - - if targetsRoleName == policy.TargetsRoleName { - state.Metadata.TargetsEnvelope = env - } else { - state.Metadata.DelegationEnvelopes[targetsRoleName] = env - } - commitMessage := fmt.Sprintf("Remove principal from policy '%s'\n%s", targetsRoleName, principalID) - - slog.Debug("Committing policy...") - return state.Commit(r.r, commitMessage, options.CreateRSLEntry, signCommit) + return r.updateTargetsMetadata(ctx, state, signer, targetsRoleName, targetsMetadata, commitMessage, options.CreateRSLEntry, signCommit) } // SignTargets adds a signature to specified Targets role's envelope. Note that @@ -634,3 +461,61 @@ func (r *Repository) SignTargets(ctx context.Context, signer sslibdsse.SignerVer slog.Debug("Committing policy...") return state.Commit(r.r, commitMessage, options.CreateRSLEntry, signCommit) } + +func (r *Repository) IncrementTargetsVersion(ctx context.Context, signer sslibdsse.SignerVerifier, targetsRoleName string, signCommit bool, opts ...trustpolicyopts.Option) error { + if signCommit { + slog.Debug("Checking if Git signing is configured...") + err := r.r.CanSign() + if err != nil { + return err + } + } + + options := &trustpolicyopts.Options{} + for _, fn := range opts { + fn(options) + } + + slog.Debug("Loading current policy...") + state, err := policy.LoadCurrentState(ctx, r.r, policy.PolicyStagingRef, policyopts.BypassRSL()) + if err != nil { + return err + } + if !state.HasTargetsRole(targetsRoleName) { + return policy.ErrMetadataNotFound + } + + slog.Debug("Loading current rule file...") + targetsMetadata, err := state.GetTargetsMetadata(targetsRoleName, true) + if err != nil { + return err + } + + // Just pass it to updateTargetsMetadata as it will increment the version + commitMessage := fmt.Sprintf("Increment rule file '%s' version", targetsRoleName) + return r.updateTargetsMetadata(ctx, state, signer, targetsRoleName, targetsMetadata, commitMessage, options.CreateRSLEntry, signCommit) +} + +func (r *Repository) updateTargetsMetadata(ctx context.Context, state *policy.State, signer sslibdsse.SignerVerifier, targetsMetadataName string, targetsMetadata tuf.TargetsMetadata, commitMessage string, createRSLEntry, signCommit bool) error { + targetsMetadata.IncrementVersion() + + env, err := dsse.CreateEnvelope(targetsMetadata) + if err != nil { + return err + } + + slog.Debug("Signing updated rule file...") + env, err = dsse.SignEnvelope(ctx, env, signer) + if err != nil { + return err + } + + if targetsMetadataName == policy.TargetsRoleName { + state.Metadata.TargetsEnvelope = env + } else { + state.Metadata.DelegationEnvelopes[targetsMetadataName] = env + } + + slog.Debug("Committing policy...") + return state.Commit(r.r, commitMessage, createRSLEntry, signCommit) +}
experimental/gittuf/targets_test.go+32 −0 modified@@ -434,3 +434,35 @@ func TestSignTargets(t *testing.T) { assert.Equal(t, 2, len(state.Metadata.TargetsEnvelope.Signatures)) } + +func TestIncrementTargetsVersion(t *testing.T) { + r := createTestRepositoryWithPolicy(t, "") + + state, err := policy.LoadCurrentState(testCtx, r.r, policy.PolicyRef) + require.Nil(t, err) + + oldTargetsMetadata, err := state.GetTargetsMetadata(policy.TargetsRoleName, false) + require.Nil(t, err) + + targetsSigner := setupSSHKeysForSigning(t, targetsKeyBytes, targetsPubKeyBytes) + + err = r.IncrementTargetsVersion(testCtx, targetsSigner, policy.TargetsRoleName, false) + assert.Nil(t, err) + + err = r.StagePolicy(testCtx, "", true, false) + require.Nil(t, err) + + state, err = policy.LoadCurrentState(testCtx, r.r, policy.PolicyStagingRef) + if err != nil { + t.Fatal(err) + } + + newTargetsMetadata, err := state.GetTargetsMetadata(policy.TargetsRoleName, false) + require.Nil(t, err) + + assert.Equal(t, oldTargetsMetadata.GetVersion()+1, newTargetsMetadata.GetVersion()) + + // Check that the metadata is the same except for the version number + newTargetsMetadata.(*tufv02.TargetsMetadata).Version = oldTargetsMetadata.GetVersion() + assert.Equal(t, oldTargetsMetadata, newTargetsMetadata) +}
internal/cmd/policy/incrementversion/incrementversion.go+58 −0 added@@ -0,0 +1,58 @@ +// Copyright The gittuf Authors +// SPDX-License-Identifier: Apache-2.0 + +package incrementversion + +import ( + "github.com/gittuf/gittuf/experimental/gittuf" + trustpolicyopts "github.com/gittuf/gittuf/experimental/gittuf/options/trustpolicy" + "github.com/gittuf/gittuf/internal/cmd/policy/persistent" + "github.com/gittuf/gittuf/internal/policy" + "github.com/spf13/cobra" +) + +type options struct { + p *persistent.Options + policyName string +} + +func (o *options) AddFlags(cmd *cobra.Command) { + cmd.Flags().StringVar( + &o.policyName, + "policy-name", + policy.TargetsRoleName, + "name of policy file to increment version of", + ) +} + +func (o *options) Run(cmd *cobra.Command, _ []string) error { + repo, err := gittuf.LoadRepository(".") + if err != nil { + return err + } + + signer, err := gittuf.LoadSigner(repo, o.p.SigningKey) + if err != nil { + return err + } + + opts := []trustpolicyopts.Option{} + if o.p.WithRSLEntry { + opts = append(opts, trustpolicyopts.WithRSLEntry()) + } + return repo.IncrementTargetsVersion(cmd.Context(), signer, o.policyName, true, opts...) +} + +func New(persistent *persistent.Options) *cobra.Command { + o := &options{p: persistent} + cmd := &cobra.Command{ + Use: "increment-version", + Short: "Increment the integer version of the specified rule file metadata", + Long: `The 'increment-version' command increments the integer version of the specified rule file metadata without making any other changes.`, + RunE: o.Run, + DisableAutoGenTag: true, + } + o.AddFlags(cmd) + + return cmd +}
internal/cmd/policy/policy.go+2 −0 modified@@ -7,6 +7,7 @@ import ( "github.com/gittuf/gittuf/internal/cmd/policy/addkey" "github.com/gittuf/gittuf/internal/cmd/policy/addperson" "github.com/gittuf/gittuf/internal/cmd/policy/addrule" + "github.com/gittuf/gittuf/internal/cmd/policy/incrementversion" i "github.com/gittuf/gittuf/internal/cmd/policy/init" "github.com/gittuf/gittuf/internal/cmd/policy/listprincipals" "github.com/gittuf/gittuf/internal/cmd/policy/listrules" @@ -41,6 +42,7 @@ func New() *cobra.Command { cmd.AddCommand(apply.New()) cmd.AddCommand(discard.New()) cmd.AddCommand(i.New(o)) + cmd.AddCommand(incrementversion.New(o)) cmd.AddCommand(listprincipals.New()) cmd.AddCommand(listrules.New()) cmd.AddCommand(remote.New())
internal/cmd/trust/incrementversion/incrementversion.go+46 −0 added@@ -0,0 +1,46 @@ +// Copyright The gittuf Authors +// SPDX-License-Identifier: Apache-2.0 + +package incrementversion + +import ( + "github.com/gittuf/gittuf/experimental/gittuf" + trustpolicyopts "github.com/gittuf/gittuf/experimental/gittuf/options/trustpolicy" + "github.com/gittuf/gittuf/internal/cmd/trust/persistent" + "github.com/spf13/cobra" +) + +type options struct { + p *persistent.Options +} + +func (o *options) Run(cmd *cobra.Command, _ []string) error { + repo, err := gittuf.LoadRepository(".") + if err != nil { + return err + } + + signer, err := gittuf.LoadSigner(repo, o.p.SigningKey) + if err != nil { + return err + } + + opts := []trustpolicyopts.Option{} + if o.p.WithRSLEntry { + opts = append(opts, trustpolicyopts.WithRSLEntry()) + } + return repo.IncrementRootVersion(cmd.Context(), signer, true, opts...) +} + +func New(persistent *persistent.Options) *cobra.Command { + o := &options{p: persistent} + cmd := &cobra.Command{ + Use: "increment-version", + Short: "Increment the integer version of the root metadata", + Long: `The 'increment-version' command increments the integer version of the root metadata without making any other changes.`, + RunE: o.Run, + DisableAutoGenTag: true, + } + + return cmd +}
internal/cmd/trust/trust.go+2 −0 modified@@ -14,6 +14,7 @@ import ( "github.com/gittuf/gittuf/internal/cmd/trust/addrootkey" "github.com/gittuf/gittuf/internal/cmd/trust/disablegithubappapprovals" "github.com/gittuf/gittuf/internal/cmd/trust/enablegithubappapprovals" + "github.com/gittuf/gittuf/internal/cmd/trust/incrementversion" i "github.com/gittuf/gittuf/internal/cmd/trust/init" "github.com/gittuf/gittuf/internal/cmd/trust/inspectroot" "github.com/gittuf/gittuf/internal/cmd/trust/listglobalrules" @@ -62,6 +63,7 @@ func New() *cobra.Command { cmd.AddCommand(apply.New()) cmd.AddCommand(disablegithubappapprovals.New(o)) cmd.AddCommand(enablegithubappapprovals.New(o)) + cmd.AddCommand(incrementversion.New(o)) cmd.AddCommand(inspectroot.New()) cmd.AddCommand(listglobalrules.New()) cmd.AddCommand(listhooks.New())
internal/policy/helpers_test.go+177 −0 modified@@ -171,6 +171,42 @@ func createTestStateWithOnlyRoot(t *testing.T) *State { } } +func createTestStateWithOnlyRootUnnumbered(t *testing.T) *State { + // This is a clone of createTestStateWithOnlyRoot but with the version of + // the root metadata overridden to be 0 (unnumbered). This allows us to test + // the behavior of transitioning from unnumbered to numbered metadata, which + // is relevant for repositories that had existing metadata before gittuf was + // introduced. + + t.Helper() + + signer := setupSSHKeysForSigning(t, rootKeyBytes, rootPubKeyBytes) //nolint:staticcheck + key := tufv01.NewKeyFromSSLibKey(signer.MetadataKey()) + + rootMetadata, err := InitializeRootMetadata(key) + if err != nil { + t.Fatal(err) + } + + // Override the version to be 0 + rootMetadata.(*tufv02.RootMetadata).Version = 0 + + rootEnv, err := dsse.CreateEnvelope(rootMetadata) + if err != nil { + t.Fatal(err) + } + rootEnv, err = dsse.SignEnvelope(context.Background(), rootEnv, signer) + if err != nil { + t.Fatal(err) + } + + return &State{ + Metadata: &StateMetadata{ + RootEnvelope: rootEnv, + }, + } +} + func createTestStateWithPolicy(t *testing.T) *State { t.Helper() @@ -236,6 +272,147 @@ func createTestStateWithPolicy(t *testing.T) *State { return state } +func createTestStateWithPolicyUnnumbered(t *testing.T) *State { + // This is a clone of createTestStateWithPolicy but with the version of the + // metadata overridden to be 0 (unnumbered). This allows us to test the + // behavior of transitioning from unnumbered to numbered metadata, which is + // relevant for repositories that had existing metadata before gittuf was + // introduced. + t.Helper() + + signer := setupSSHKeysForSigning(t, rootKeyBytes, rootPubKeyBytes) + key := tufv01.NewKeyFromSSLibKey(signer.MetadataKey()) + + rootMetadata, err := InitializeRootMetadata(key) + if err != nil { + t.Fatal(err) + } + + // Override the version to be 0 + rootMetadata.(*tufv02.RootMetadata).Version = 0 + + if err := rootMetadata.AddPrimaryRuleFilePrincipal(key); err != nil { + t.Fatal(err) + } + + rootEnv, err := dsse.CreateEnvelope(rootMetadata) + if err != nil { + t.Fatal(err) + } + rootEnv, err = dsse.SignEnvelope(context.Background(), rootEnv, signer) + if err != nil { + t.Fatal(err) + } + + gpgKeyR, err := gpg.LoadGPGKeyFromBytes(gpgPubKeyBytes) + if err != nil { + t.Fatal(err) + } + gpgKey := tufv01.NewKeyFromSSLibKey(gpgKeyR) + + targetsMetadata := InitializeTargetsMetadata() + if err := targetsMetadata.AddPrincipal(gpgKey); err != nil { + t.Fatal(err) + } + if err := targetsMetadata.AddRule("protect-main", []string{gpgKey.KeyID}, []string{"git:refs/heads/main"}, 1); err != nil { + t.Fatal(err) + } + // Add a file protection rule. When used with common.AddNTestCommitsToSpecifiedRef, we have files with names 1, 2, 3,...n. + if err := targetsMetadata.AddRule("protect-files-1-and-2", []string{gpgKey.KeyID}, []string{"file:1", "file:2"}, 1); err != nil { + t.Fatal(err) + } + + // Override the version to be 0 + targetsMetadata.(*tufv02.TargetsMetadata).Version = 0 + + targetsEnv, err := dsse.CreateEnvelope(targetsMetadata) + if err != nil { + t.Fatal(err) + } + targetsEnv, err = dsse.SignEnvelope(context.Background(), targetsEnv, signer) + if err != nil { + t.Fatal(err) + } + + state := &State{ + Metadata: &StateMetadata{ + RootEnvelope: rootEnv, + TargetsEnvelope: targetsEnv, + }, + } + + if err := state.preprocess(); err != nil { + t.Fatal(err) + } + + return state +} + +func createTestStateWithPolicyTargetsSignedByWrongKey(t *testing.T) *State { + rootSigner := setupSSHKeysForSigning(t, rootKeyBytes, rootPubKeyBytes) + key := tufv01.NewKeyFromSSLibKey(rootSigner.MetadataKey()) + + rootMetadata, err := InitializeRootMetadata(key) + if err != nil { + t.Fatal(err) + } + + if err := rootMetadata.AddPrimaryRuleFilePrincipal(key); err != nil { + t.Fatal(err) + } + + rootEnv, err := dsse.CreateEnvelope(rootMetadata) + if err != nil { + t.Fatal(err) + } + rootEnv, err = dsse.SignEnvelope(context.Background(), rootEnv, rootSigner) + if err != nil { + t.Fatal(err) + } + + gpgKeyR, err := gpg.LoadGPGKeyFromBytes(gpgPubKeyBytes) + if err != nil { + t.Fatal(err) + } + gpgKey := tufv01.NewKeyFromSSLibKey(gpgKeyR) + + targetsSigner := setupSSHKeysForSigning(t, targets1KeyBytes, targets1PubKeyBytes) + + targetsMetadata := InitializeTargetsMetadata() + if err := targetsMetadata.AddPrincipal(gpgKey); err != nil { + t.Fatal(err) + } + if err := targetsMetadata.AddRule("protect-main", []string{gpgKey.KeyID}, []string{"git:refs/heads/main"}, 1); err != nil { + t.Fatal(err) + } + // Add a file protection rule. When used with common.AddNTestCommitsToSpecifiedRef, we have files with names 1, 2, 3,...n. + if err := targetsMetadata.AddRule("protect-files-1-and-2", []string{gpgKey.KeyID}, []string{"file:1", "file:2"}, 1); err != nil { + t.Fatal(err) + } + + targetsEnv, err := dsse.CreateEnvelope(targetsMetadata) + if err != nil { + t.Fatal(err) + } + targetsEnv, err = dsse.SignEnvelope(context.Background(), targetsEnv, targetsSigner) + if err != nil { + t.Fatal(err) + } + + state := &State{ + Metadata: &StateMetadata{ + RootEnvelope: rootEnv, + TargetsEnvelope: targetsEnv, + }, + } + + if err := state.preprocess(); err != nil { + t.Fatal(err) + } + + return state +} + // createTestStateWithGlobalConstraintThreshold creates a policy state with no // explicit branch protection rules but with a two-approval constraint on // changes to the main branch. The two keys trusted are `rootPubKeyBytes` and
internal/policy/policy.go+113 −105 modified@@ -80,6 +80,112 @@ type StateMetadata struct { DelegationEnvelopes map[string]*sslibdsse.Envelope } +func (s *StateMetadata) GetRootMetadata(migrate bool) (tuf.RootMetadata, error) { + payloadBytes, err := s.RootEnvelope.DecodeB64Payload() + if err != nil { + return nil, err + } + return s.getRootMetadataFromBytes(payloadBytes, migrate) +} + +func (s *StateMetadata) getRootMetadataFromBytes(metadataBytes []byte, migrate bool) (tuf.RootMetadata, error) { + inspectRootMetadata := map[string]any{} + if err := json.Unmarshal(metadataBytes, &inspectRootMetadata); err != nil { + return nil, fmt.Errorf("unable to unmarshal root metadata: %w", err) + } + + schemaVersion, hasSchemaVersion := inspectRootMetadata["schemaVersion"] + switch { + case !hasSchemaVersion: + // this is tufv01 + // Something that's not tufv01 may also lack the schemaVersion field and + // enter this code path. At that point, we're relying on the unmarshal + // to return something that's close to tufv01. We may see strange bugs + // if this happens, but it's also likely someone trying to submit + // incorrect metadata / trigger a version rollback, which we do want to + // be aware of. + rootMetadata := &tufv01.RootMetadata{} + if err := json.Unmarshal(metadataBytes, rootMetadata); err != nil { + return nil, fmt.Errorf("unable to unmarshal root metadata: %w", err) + } + + if migrate { + return migrations.MigrateRootMetadataV01ToV02(rootMetadata), nil + } + + return rootMetadata, nil + + case schemaVersion == tufv02.RootVersion: + rootMetadata := &tufv02.RootMetadata{} + if err := json.Unmarshal(metadataBytes, rootMetadata); err != nil { + return nil, fmt.Errorf("unable to unmarshal root metadata: %w", err) + } + + return rootMetadata, nil + + default: + return nil, tuf.ErrUnknownRootMetadataVersion + } +} + +func (s *StateMetadata) GetTargetsMetadata(roleName string, migrate bool) (tuf.TargetsMetadata, error) { + e := s.TargetsEnvelope + if roleName != TargetsRoleName { + env, ok := s.DelegationEnvelopes[roleName] + if !ok { + return nil, ErrMetadataNotFound + } + e = env + } + + if e == nil { + return nil, ErrMetadataNotFound + } + + payloadBytes, err := e.DecodeB64Payload() + if err != nil { + return nil, err + } + + inspectTargetsMetadata := map[string]any{} + if err := json.Unmarshal(payloadBytes, &inspectTargetsMetadata); err != nil { + return nil, fmt.Errorf("unable to unmarshal rule file metadata: %w", err) + } + + schemaVersion, hasSchemaVersion := inspectTargetsMetadata["schemaVersion"] + switch { + case !hasSchemaVersion: + // this is tufv01 + // Something that's not tufv01 may also lack the schemaVersion field and + // enter this code path. At that point, we're relying on the unmarshal + // to return something that's close to tufv01. We may see strange bugs + // if this happens, but it's also likely someone trying to submit + // incorrect metadata / trigger a version rollback, which we do want to + // be aware of. + targetsMetadata := &tufv01.TargetsMetadata{} + if err := json.Unmarshal(payloadBytes, targetsMetadata); err != nil { + return nil, fmt.Errorf("unable to unmarshal rule file metadata: %w", err) + } + + if migrate { + return migrations.MigrateTargetsMetadataV01ToV02(targetsMetadata), nil + } + + return targetsMetadata, nil + + case schemaVersion == tufv02.TargetsVersion: + targetsMetadata := &tufv02.TargetsMetadata{} + if err := json.Unmarshal(payloadBytes, targetsMetadata); err != nil { + return nil, fmt.Errorf("unable to unmarshal rule file metadata: %w", err) + } + + return targetsMetadata, nil + + default: + return nil, tuf.ErrUnknownTargetsMetadataVersion + } +} + func (s *StateMetadata) WriteTree(repo *gitinterface.Repository) (gitinterface.Hash, error) { metadata := map[string]*sslibdsse.Envelope{} metadata[RootRoleName] = s.RootEnvelope @@ -152,6 +258,10 @@ func LoadState(ctx context.Context, repo *gitinterface.Repository, requestedEntr return nil, err } + if err := state.Verify(ctx); err != nil { + return nil, fmt.Errorf("requested state has invalidly signed metadata: %w", err) + } + if len(options.InitialRootPrincipals) == 0 { slog.Debug(fmt.Sprintf("Trusting root of trust for initial policy '%s'...", firstPolicyEntry.GetID().String())) return state, nil @@ -975,11 +1085,7 @@ func (s *State) GetRootKeys() ([]tuf.Principal, error) { // The `migrate` parameter determines if the schema must be converted to a newer // version. func (s *State) GetRootMetadata(migrate bool) (tuf.RootMetadata, error) { - payloadBytes, err := s.Metadata.RootEnvelope.DecodeB64Payload() - if err != nil { - return nil, err - } - return s.getRootMetadataFromBytes(payloadBytes, migrate) + return s.Metadata.GetRootMetadata(migrate) } func (s *State) GetControllerRootMetadata(controllerName string) (tuf.RootMetadata, error) { @@ -988,112 +1094,14 @@ func (s *State) GetControllerRootMetadata(controllerName string) (tuf.RootMetada return nil, fmt.Errorf("%w: '%s'", ErrControllerMetadataNotFound, controllerName) } - payloadBytes, err := metadata.RootEnvelope.DecodeB64Payload() - if err != nil { - return nil, err - } - return s.getRootMetadataFromBytes(payloadBytes, false) // never migrate -} - -func (s *State) getRootMetadataFromBytes(metadataBytes []byte, migrate bool) (tuf.RootMetadata, error) { - inspectRootMetadata := map[string]any{} - if err := json.Unmarshal(metadataBytes, &inspectRootMetadata); err != nil { - return nil, fmt.Errorf("unable to unmarshal root metadata: %w", err) - } - - schemaVersion, hasSchemaVersion := inspectRootMetadata["schemaVersion"] - switch { - case !hasSchemaVersion: - // this is tufv01 - // Something that's not tufv01 may also lack the schemaVersion field and - // enter this code path. At that point, we're relying on the unmarshal - // to return something that's close to tufv01. We may see strange bugs - // if this happens, but it's also likely someone trying to submit - // incorrect metadata / trigger a version rollback, which we do want to - // be aware of. - rootMetadata := &tufv01.RootMetadata{} - if err := json.Unmarshal(metadataBytes, rootMetadata); err != nil { - return nil, fmt.Errorf("unable to unmarshal root metadata: %w", err) - } - - if migrate { - return migrations.MigrateRootMetadataV01ToV02(rootMetadata), nil - } - - return rootMetadata, nil - - case schemaVersion == tufv02.RootVersion: - rootMetadata := &tufv02.RootMetadata{} - if err := json.Unmarshal(metadataBytes, rootMetadata); err != nil { - return nil, fmt.Errorf("unable to unmarshal root metadata: %w", err) - } - - return rootMetadata, nil - - default: - return nil, tuf.ErrUnknownRootMetadataVersion - } + return metadata.GetRootMetadata(false) // never migrate controller metadata } // GetTargetsMetadata returns the deserialized payload of the State's // TargetsEnvelope for the specified `roleName`. The `migrate` parameter // determines if the schema must be converted to a newer version. func (s *State) GetTargetsMetadata(roleName string, migrate bool) (tuf.TargetsMetadata, error) { - e := s.Metadata.TargetsEnvelope - if roleName != TargetsRoleName { - env, ok := s.Metadata.DelegationEnvelopes[roleName] - if !ok { - return nil, ErrMetadataNotFound - } - e = env - } - - if e == nil { - return nil, ErrMetadataNotFound - } - - payloadBytes, err := e.DecodeB64Payload() - if err != nil { - return nil, err - } - - inspectTargetsMetadata := map[string]any{} - if err := json.Unmarshal(payloadBytes, &inspectTargetsMetadata); err != nil { - return nil, fmt.Errorf("unable to unmarshal rule file metadata: %w", err) - } - - schemaVersion, hasSchemaVersion := inspectTargetsMetadata["schemaVersion"] - switch { - case !hasSchemaVersion: - // this is tufv01 - // Something that's not tufv01 may also lack the schemaVersion field and - // enter this code path. At that point, we're relying on the unmarshal - // to return something that's close to tufv01. We may see strange bugs - // if this happens, but it's also likely someone trying to submit - // incorrect metadata / trigger a version rollback, which we do want to - // be aware of. - targetsMetadata := &tufv01.TargetsMetadata{} - if err := json.Unmarshal(payloadBytes, targetsMetadata); err != nil { - return nil, fmt.Errorf("unable to unmarshal rule file metadata: %w", err) - } - - if migrate { - return migrations.MigrateTargetsMetadataV01ToV02(targetsMetadata), nil - } - - return targetsMetadata, nil - - case schemaVersion == tufv02.TargetsVersion: - targetsMetadata := &tufv02.TargetsMetadata{} - if err := json.Unmarshal(payloadBytes, targetsMetadata); err != nil { - return nil, fmt.Errorf("unable to unmarshal rule file metadata: %w", err) - } - - return targetsMetadata, nil - - default: - return nil, tuf.ErrUnknownTargetsMetadataVersion - } + return s.Metadata.GetTargetsMetadata(roleName, migrate) } func (s *State) HasTargetsRole(roleName string) bool {
internal/policy/policy_test.go+87 −0 modified@@ -112,6 +112,93 @@ func TestLoadState(t *testing.T) { assertStatesEqual(t, state, loadedState) }) + t.Run("fail loading when first targets is signed by wrong key", func(t *testing.T) { + // We can't use createTestRepository because we want to bypass the + // guardrails in state.Commit and state.Apply. + state := createTestStateWithPolicyTargetsSignedByWrongKey(t) + tempDir := t.TempDir() + repo := gitinterface.CreateTestGitRepository(t, tempDir, false) + state.repository = repo + + if err := state.Commit(repo, "Initial state", true, false); err != nil { + t.Fatal(err) + } + policyStagingTip, err := repo.GetReference(PolicyStagingRef) + require.Nil(t, err) + + if err := repo.SetReference(PolicyRef, policyStagingTip); err != nil { + t.Fatal(err) + } + + if err := rsl.NewReferenceEntry(PolicyRef, policyStagingTip).Commit(repo, false); err != nil { + t.Fatal(err) + } + + // Now we have the policy committed bypassing guardrails, where the + // first policy has the wrongly signed metadata. Load and ensure we see + // an error. + + entry, err := rsl.GetLatestEntry(repo) + require.Nil(t, err) + + loadedState, err := LoadState(t.Context(), repo, entry.(*rsl.ReferenceEntry)) + require.Error(t, err) + require.Nil(t, loadedState) + }) + + t.Run("fail loading when targets is signed by invalid metadata", func(t *testing.T) { + repo, state := createTestRepository(t, createTestStateWithPolicy) + + rootEnv := state.Metadata.RootEnvelope + + targetsMetadata, err := state.GetTargetsMetadata(TargetsRoleName, false) + require.Nil(t, err) + + // Re-sign using wrong key and commit bypassing guardrails to ensure we + // validate when loading state + targetsEnv, err := dsse.CreateEnvelope(targetsMetadata) + require.Nil(t, err) + + // The state creator uses the root key for the primary rule file + // Sign with a different key + invalidSigner := setupSSHKeysForSigning(t, targets1KeyBytes, targets1PubKeyBytes) + targetsEnv, err = dsse.SignEnvelope(t.Context(), targetsEnv, invalidSigner) + require.Nil(t, err) + + newState := &State{ + Metadata: &StateMetadata{ + RootEnvelope: rootEnv, + TargetsEnvelope: targetsEnv, + }, + } + + if err := newState.preprocess(); err != nil { + t.Fatal(err) + } + + if err := newState.Commit(repo, "Updated state", true, false); err != nil { + t.Fatal(err) + } + + policyStagingTip, err := repo.GetReference(PolicyStagingRef) + require.Nil(t, err) + + if err := repo.SetReference(PolicyRef, policyStagingTip); err != nil { + t.Fatal(err) + } + + if err := rsl.NewReferenceEntry(PolicyRef, policyStagingTip).Commit(repo, false); err != nil { + t.Fatal(err) + } + + entry, err := rsl.GetLatestEntry(repo) + require.Nil(t, err) + + loadedState, err := LoadState(t.Context(), repo, entry.(*rsl.ReferenceEntry)) + require.Error(t, err) + require.Nil(t, loadedState) + }) + t.Run("fail loading while verifying multiple states, bad sig", func(t *testing.T) { repo, state := createTestRepository(t, createTestStateWithPolicy) signer := setupSSHKeysForSigning(t, rootKeyBytes, rootPubKeyBytes)
internal/policy/verify.go+98 −2 modified@@ -38,6 +38,7 @@ var ( ErrCannotVerifyMergeableForTagRef = errors.New("cannot verify mergeable into tag reference") ErrNetworkRepositoryDoesNotDeclareRequiredController = errors.New("network repository does not declare required controller repository") ErrNetworkRepositoryHasStaleControllerMetadata = errors.New("network repository has not fetched latest controller metadata") + ErrMetadataRollbackDetected = errors.New("gittuf policy metadata rollback detected") ) // PolicyVerifier implements various gittuf verification workflows. @@ -734,6 +735,80 @@ func (v *PolicyVerifier) VerifyRelativeForRef(ctx context.Context, firstEntry, l return nil } +func (s *StateMetadata) VerifyNewStateMetadata(_ context.Context, newStateMetadata *StateMetadata) error { + // Check new state's root version number is >= current state's root version number + currentRootMetadata, err := s.GetRootMetadata(false) + if err != nil { + return err + } + newRootMetadata, err := newStateMetadata.GetRootMetadata(false) + if err != nil { + return err + } + if newRootMetadata.GetVersion() < currentRootMetadata.GetVersion() { + return fmt.Errorf("%w: new policy root version is older than current policy root version, %d < %d", ErrMetadataRollbackDetected, newRootMetadata.GetVersion(), currentRootMetadata.GetVersion()) + } + + // Check new state's rule files have version numbers >= current state's rule file version numbers + + // First off, compare the primary rule file + currentTargetsMetadata, err := s.GetTargetsMetadata(TargetsRoleName, false) + if err != nil { + if errors.Is(err, ErrMetadataNotFound) { + // If the current state doesn't have a primary rule file, we can + // return early as there is no risk of rollback with the rule files + // in the new state. + return nil + } + + return err + } + newTargetsMetadata, err := newStateMetadata.GetTargetsMetadata(TargetsRoleName, false) + if err != nil { + // At this point, we know the current state has a primary rule file, so + // if the new state doesn't have one, it's a rollback. This MAY change + // when we support deleting rule files, though maybe we don't allow that + // for primary rule files. + if errors.Is(err, ErrMetadataNotFound) { + return fmt.Errorf("%w: new policy is missing primary rule file", ErrMetadataRollbackDetected) + } + + return err + } + if newTargetsMetadata.GetVersion() < currentTargetsMetadata.GetVersion() { + return fmt.Errorf("%w: new policy primary rule file version is older than current policy primary rule file version, %d < %d", ErrMetadataRollbackDetected, newTargetsMetadata.GetVersion(), currentTargetsMetadata.GetVersion()) + } + + // Then compare all delegated rule files. + // We need to check that the rule files in the new state with the same name + // as a rule file in the current state have greater or equal version numbers. + // The new state may have a rule file added that doesn't exist in the + // current state, but it shouldn't have any removed rule files compared to + // the current state as we don't yet support deleting rule files. + for name := range s.DelegationEnvelopes { + currentDelegatedTargetsMetadata, err := s.GetTargetsMetadata(name, true) + if err != nil { + return err + } + newDelegatedTargetsMetadata, err := newStateMetadata.GetTargetsMetadata(name, true) + if err != nil { + // At this point, we know the current state has this delegated rule + // file, so if the new state doesn't have it, it's a rollback. + // This will change once we support deleting rule files. + if errors.Is(err, ErrMetadataNotFound) { + return fmt.Errorf("%w: new policy is missing delegated rule file '%s'", ErrMetadataRollbackDetected, name) + } + + return err + } + if newDelegatedTargetsMetadata.GetVersion() < currentDelegatedTargetsMetadata.GetVersion() { + return fmt.Errorf("%w: new policy delegated rule file '%s' version is older than current policy delegated rule file version, %d < %d", ErrMetadataRollbackDetected, name, newDelegatedTargetsMetadata.GetVersion(), currentDelegatedTargetsMetadata.GetVersion()) + } + } + + return nil +} + // VerifyNewState ensures that when a new policy is encountered, its root role // is signed by keys trusted in the current policy. func (s *State) VerifyNewState(ctx context.Context, newPolicy *State) error { @@ -742,8 +817,29 @@ func (s *State) VerifyNewState(ctx context.Context, newPolicy *State) error { return err } - _, err = rootVerifier.Verify(ctx, gitinterface.ZeroHash, newPolicy.Metadata.RootEnvelope) - return err + if _, err := rootVerifier.Verify(ctx, gitinterface.ZeroHash, newPolicy.Metadata.RootEnvelope); err != nil { + return err + } + + // Verify state metadata to protect against rollback attacks + if err := s.Metadata.VerifyNewStateMetadata(ctx, newPolicy.Metadata); err != nil { + return err + } + + // Verify controller state metadata to protect against rollback attacks upstream + for name, controllerMetadata := range s.ControllerMetadata { + newControllerMetadata, hasController := newPolicy.ControllerMetadata[name] + if !hasController { + slog.Debug(fmt.Sprintf("new policy does not have metadata for the controller '%s', skipping check for rollback of its metadata...", name)) + continue + } + + if err := controllerMetadata.VerifyNewStateMetadata(ctx, newControllerMetadata); err != nil { + return fmt.Errorf("controller '%s' metadata failed new state metadata verification: %w", name, err) + } + } + + return nil } // verifyEntry is a helper to verify an entry's signature using the specified
internal/policy/verify_test.go+42 −0 modified@@ -4002,6 +4002,48 @@ func TestStateVerifyNewState(t *testing.T) { err = currentPolicy.VerifyNewState(testCtx, newPolicy) assert.ErrorIs(t, err, ErrVerifierConditionsUnmet) }) + + t.Run("rollback attack", func(t *testing.T) { + t.Parallel() + + oldPolicy := createTestStateWithOnlyRoot(t) + newPolicy := createTestStateWithPolicy(t) + + err := oldPolicy.VerifyNewState(testCtx, newPolicy) + assert.Nil(t, err) + + // The reverse should fail + err = newPolicy.VerifyNewState(testCtx, oldPolicy) + assert.ErrorIs(t, err, ErrMetadataRollbackDetected) + }) + + t.Run("verify new state when transitioning from non numbered to numbered metadata", func(t *testing.T) { + t.Parallel() + + oldPolicy := createTestStateWithOnlyRootUnnumbered(t) + newPolicy := createTestStateWithPolicy(t) + + err := oldPolicy.VerifyNewState(testCtx, newPolicy) + assert.Nil(t, err) + + // The reverse should fail + err = newPolicy.VerifyNewState(testCtx, oldPolicy) + assert.ErrorIs(t, err, ErrMetadataRollbackDetected) + }) + + t.Run("verify new state when transitioning from non-numbered to numbered metadata with both policy and root", func(t *testing.T) { + t.Parallel() + + oldPolicy := createTestStateWithPolicyUnnumbered(t) + newPolicy := createTestStateWithPolicy(t) + + err := oldPolicy.VerifyNewState(testCtx, newPolicy) + assert.Nil(t, err) + + // The reverse should fail + err = newPolicy.VerifyNewState(testCtx, oldPolicy) + assert.ErrorIs(t, err, ErrMetadataRollbackDetected) + }) } func getPropagationDirectivesForNetworkRepository(t *testing.T, rootMetadata tuf.RootMetadata) []tuf.PropagationDirective {
internal/signerverifier/dsse/dsse_test.go+1 −1 modified@@ -22,7 +22,7 @@ func TestCreateEnvelope(t *testing.T) { env, err := CreateEnvelope(rootMetadata) assert.Nil(t, err) assert.Equal(t, PayloadType, env.PayloadType) - assert.Equal(t, "eyJ0eXBlIjoicm9vdCIsImV4cGlyZXMiOiIiLCJrZXlzIjpudWxsLCJyb2xlcyI6bnVsbH0=", env.Payload) + assert.Equal(t, "eyJ0eXBlIjoicm9vdCIsImV4cGlyZXMiOiIiLCJ2ZXJzaW9uIjoxLCJrZXlzIjpudWxsLCJyb2xlcyI6bnVsbH0=", env.Payload) } func TestSignEnvelope(t *testing.T) {
internal/tuf/migrations/migrations.go+6 −0 modified@@ -24,6 +24,9 @@ func MigrateRootMetadataV01ToV02(rootMetadata *tufv01.RootMetadata) *tufv02.Root // Set same expires newRootMetadata.Expires = rootMetadata.Expires + // Set same version number + newRootMetadata.Version = rootMetadata.Version + // Set repository location newRootMetadata.RepositoryLocation = rootMetadata.RepositoryLocation @@ -90,6 +93,9 @@ func MigrateTargetsMetadataV01ToV02(targetsMetadata *tufv01.TargetsMetadata) *tu // Set same expires newTargetsMetadata.Expires = targetsMetadata.Expires + // Set same version number + newTargetsMetadata.Version = targetsMetadata.Version + // Set delegations newTargetsMetadata.Delegations = &tufv02.Delegations{ Principals: map[string]tuf.Principal{},
internal/tuf/tuf.go+14 −4 modified@@ -94,8 +94,13 @@ type RootMetadata interface { // unenforced SetExpires(expiry string) - // SchemaVersion returns the metadata schema version. - SchemaVersion() string + // GetSchemaVersion returns the metadata schema version. + GetSchemaVersion() string + + // GetVersion returns the version number of the metadata. + GetVersion() uint64 + // IncrementVersion increments the version number of the metadata by 1. + IncrementVersion() // GetRepositoryLocation returns the canonical location of the Git // repository. @@ -234,8 +239,13 @@ type TargetsMetadata interface { // unenforced SetExpires(expiry string) - // SchemaVersion returns the metadata schema version. - SchemaVersion() string + // GetSchemaVersion returns the metadata schema version. + GetSchemaVersion() string + + // GetVersion returns the version number of the metadata. + GetVersion() uint64 + // IncrementVersion increments the version number of the metadata by 1. + IncrementVersion() // GetPrincipals returns all the principals in the rule file. GetPrincipals() map[string]Principal
internal/tuf/v01/root.go+15 −3 modified@@ -22,6 +22,7 @@ const ( type RootMetadata struct { Type string `json:"type"` Expires string `json:"expires"` + Version uint64 `json:"version"` RepositoryLocation string `json:"repositoryLocation,omitempty"` Keys map[string]*Key `json:"keys"` Roles map[string]Role `json:"roles"` @@ -35,7 +36,8 @@ type RootMetadata struct { // NewRootMetadata returns a new instance of RootMetadata. func NewRootMetadata() *RootMetadata { return &RootMetadata{ - Type: "root", + Type: "root", + Version: 1, } } @@ -44,11 +46,21 @@ func (r *RootMetadata) SetExpires(expires string) { r.Expires = expires } -// SchemaVersion returns the metadata schema version. -func (r *RootMetadata) SchemaVersion() string { +// GetSchemaVersion returns the metadata schema version. +func (r *RootMetadata) GetSchemaVersion() string { return rootVersion } +// GetVersion returns the version number of the metadata. +func (r *RootMetadata) GetVersion() uint64 { + return r.Version +} + +// IncrementVersion increments the metadata version number by 1. +func (r *RootMetadata) IncrementVersion() { + r.Version++ +} + // GetRepositoryLocation returns the canonical location of the Git repository. func (r *RootMetadata) GetRepositoryLocation() string { return r.RepositoryLocation
internal/tuf/v01/root_test.go+2 −2 modified@@ -45,8 +45,8 @@ func TestRootMetadata(t *testing.T) { assert.True(t, rootMetadata.Roles["targets"].KeyIDs.Has(key.KeyID)) }) - t.Run("test SchemaVersion", func(t *testing.T) { - schemaVersion := rootMetadata.SchemaVersion() + t.Run("test GetSchemaVersion", func(t *testing.T) { + schemaVersion := rootMetadata.GetSchemaVersion() assert.Equal(t, rootVersion, schemaVersion) })
internal/tuf/v01/targets.go+14 −2 modified@@ -21,6 +21,7 @@ const ( type TargetsMetadata struct { Type string `json:"type"` Expires string `json:"expires"` + Version uint64 `json:"version"` Targets map[string]any `json:"targets"` Delegations *Delegations `json:"delegations"` } @@ -29,6 +30,7 @@ type TargetsMetadata struct { func NewTargetsMetadata() *TargetsMetadata { return &TargetsMetadata{ Type: "targets", + Version: 1, Delegations: &Delegations{Roles: []*Delegation{AllowRule()}}, } } @@ -39,11 +41,21 @@ func (t *TargetsMetadata) SetExpires(expires string) { t.Expires = expires } -// SchemaVersion returns the metadata schema version. -func (t *TargetsMetadata) SchemaVersion() string { +// GetSchemaVersion returns the metadata schema version. +func (t *TargetsMetadata) GetSchemaVersion() string { return targetsVersion } +// GetVersion returns the version number of the metadata. +func (t *TargetsMetadata) GetVersion() uint64 { + return t.Version +} + +// IncrementVersion increments the metadata version number by 1. +func (t *TargetsMetadata) IncrementVersion() { + t.Version++ +} + // Validate ensures the instance of TargetsMetadata matches gittuf expectations. func (t *TargetsMetadata) Validate() error { if len(t.Targets) != 0 {
internal/tuf/v02/root.go+21 −7 modified@@ -19,8 +19,9 @@ const ( // RootMetadata defines the schema of TUF's Root role. type RootMetadata struct { Type string `json:"type"` - Version string `json:"schemaVersion"` + SchemaVersion string `json:"schemaVersion"` Expires string `json:"expires"` + Version uint64 `json:"version"` RepositoryLocation string `json:"repositoryLocation,omitempty"` Principals map[string]tuf.Principal `json:"principals"` Roles map[string]Role `json:"roles"` @@ -34,8 +35,9 @@ type RootMetadata struct { // NewRootMetadata returns a new instance of RootMetadata. func NewRootMetadata() *RootMetadata { return &RootMetadata{ - Type: "root", - Version: RootVersion, + Type: "root", + SchemaVersion: RootVersion, + Version: 1, } } @@ -44,11 +46,21 @@ func (r *RootMetadata) SetExpires(expires string) { r.Expires = expires } -// SchemaVersion returns the metadata schema version. -func (r *RootMetadata) SchemaVersion() string { +// GetSchemaVersion returns the metadata schema version. +func (r *RootMetadata) GetSchemaVersion() string { + return r.SchemaVersion +} + +// GetVersion returns the metadata version number. +func (r *RootMetadata) GetVersion() uint64 { return r.Version } +// IncrementVersion increments the metadata version number by 1. +func (r *RootMetadata) IncrementVersion() { + r.Version++ +} + // GetRepositoryLocation returns the canonical location of the Git repository. func (r *RootMetadata) GetRepositoryLocation() string { return r.RepositoryLocation @@ -352,8 +364,9 @@ func (r *RootMetadata) UnmarshalJSON(data []byte) error { // json.RawMessage in place of tuf interfaces type tempType struct { Type string `json:"type"` - Version string `json:"schemaVersion"` + SchemaVersion string `json:"schemaVersion"` Expires string `json:"expires"` + Version uint64 `json:"version"` RepositoryLocation string `json:"repositoryLocation,omitempty"` Principals map[string]json.RawMessage `json:"principals"` Roles map[string]Role `json:"roles"` @@ -370,8 +383,9 @@ func (r *RootMetadata) UnmarshalJSON(data []byte) error { } r.Type = temp.Type - r.Version = temp.Version + r.SchemaVersion = temp.SchemaVersion r.Expires = temp.Expires + r.Version = temp.Version r.RepositoryLocation = temp.RepositoryLocation r.Principals = make(map[string]tuf.Principal)
internal/tuf/v02/root_test.go+2 −2 modified@@ -53,8 +53,8 @@ func TestRootMetadata(t *testing.T) { assert.True(t, rootMetadata.Roles["targets"].PrincipalIDs.Has(key.KeyID)) }) - t.Run("test SchemaVersion", func(t *testing.T) { - schemaVersion := rootMetadata.SchemaVersion() + t.Run("test GetSchemaVersion", func(t *testing.T) { + schemaVersion := rootMetadata.GetSchemaVersion() assert.Equal(t, RootVersion, schemaVersion) })
internal/tuf/v02/targets.go+22 −10 modified@@ -22,19 +22,21 @@ var ErrTargetsNotEmpty = errors.New("`targets` field in gittuf Targets metadata // TargetsMetadata defines the schema of TUF's Targets role. type TargetsMetadata struct { - Type string `json:"type"` - Version string `json:"schemaVersion"` - Expires string `json:"expires"` - Targets map[string]any `json:"targets"` - Delegations *Delegations `json:"delegations"` + Type string `json:"type"` + SchemaVersion string `json:"schemaVersion"` + Expires string `json:"expires"` + Version uint64 `json:"version"` + Targets map[string]any `json:"targets"` + Delegations *Delegations `json:"delegations"` } // NewTargetsMetadata returns a new instance of TargetsMetadata. func NewTargetsMetadata() *TargetsMetadata { return &TargetsMetadata{ - Type: "targets", - Version: TargetsVersion, - Delegations: &Delegations{Roles: []*Delegation{AllowRule()}}, + Type: "targets", + SchemaVersion: TargetsVersion, + Version: 1, + Delegations: &Delegations{Roles: []*Delegation{AllowRule()}}, } } @@ -44,11 +46,21 @@ func (t *TargetsMetadata) SetExpires(expires string) { t.Expires = expires } -// SchemaVersion returns the metadata schema version. -func (t *TargetsMetadata) SchemaVersion() string { +// GetSchemaVersion returns the metadata schema version. +func (t *TargetsMetadata) GetSchemaVersion() string { + return t.SchemaVersion +} + +// GetVersion returns the metadata version number. +func (t *TargetsMetadata) GetVersion() uint64 { return t.Version } +// IncrementVersion increments the metadata version number by 1. +func (t *TargetsMetadata) IncrementVersion() { + t.Version++ +} + // Validate ensures the instance of TargetsMetadata matches gittuf expectations. func (t *TargetsMetadata) Validate() error { if len(t.Targets) != 0 {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.