diff --git a/app/controlplane/internal/service/service.go b/app/controlplane/internal/service/service.go index 386a0af63..5871b4ce6 100644 --- a/app/controlplane/internal/service/service.go +++ b/app/controlplane/internal/service/service.go @@ -543,6 +543,11 @@ func handleUseCaseErr(ctx context.Context, err error, l *log.Helper) error { return status.Error(codes.AlreadyExists, err.Error()) case biz.IsErrReleasedVersionImmutable(err): return status.Error(codes.FailedPrecondition, err.Error()) + case biz.IsErrInvalidPassphrase(err): + // Client-side condition: the state was encrypted with a different token + // than the one used to resume the attestation. Surface it as a known + // error instead of masking it and reporting it to Sentry. + return status.Error(codes.FailedPrecondition, err.Error()) default: // Client errors already converted by this function can be processed again // (e.g. AttestationService.Store wraps storeAttestation, which converts internally). diff --git a/app/controlplane/internal/service/service_test.go b/app/controlplane/internal/service/service_test.go index 2bf2a8bae..40beba66d 100644 --- a/app/controlplane/internal/service/service_test.go +++ b/app/controlplane/internal/service/service_test.go @@ -133,6 +133,15 @@ func TestHandleUseCaseErr(t *testing.T) { wantCode: codes.NotFound, wantMessage: "failed to find membership: organization chainloop not found", }, + { + // An attestation resumed with a different token cannot be decrypted. + // It is a client-side condition, so it must reach the user as a + // FailedPrecondition naming the cause instead of a masked 500. + name: "invalid passphrase maps to failed precondition", + err: fmt.Errorf("failed to decrypt attestation state: %w", biz.NewErrInvalidPassphrase()), + wantCode: codes.FailedPrecondition, + wantMessage: "failed to decrypt attestation state: " + biz.NewErrInvalidPassphrase().Error(), + }, } for _, tc := range testCases { diff --git a/app/controlplane/pkg/biz/attestationstate.go b/app/controlplane/pkg/biz/attestationstate.go index faa90dfce..af4da45a3 100644 --- a/app/controlplane/pkg/biz/attestationstate.go +++ b/app/controlplane/pkg/biz/attestationstate.go @@ -274,7 +274,9 @@ func decrypt(ciphertext []byte, passphrase string) ([]byte, error) { // Check if the decrypted data starts with the magic string if string(ciphertext[:len(magic)]) != magic { - return nil, fmt.Errorf("incorrect passphrase") + // A mismatch means the state was encrypted with a different passphrase, + // i.e. a different request token, not a server-side failure. + return nil, NewErrInvalidPassphrase() } return ciphertext[len(magic):], nil diff --git a/app/controlplane/pkg/biz/attestationstate_integration_test.go b/app/controlplane/pkg/biz/attestationstate_integration_test.go index d867d486a..bb52af53c 100644 --- a/app/controlplane/pkg/biz/attestationstate_integration_test.go +++ b/app/controlplane/pkg/biz/attestationstate_integration_test.go @@ -160,7 +160,8 @@ func (s *attestationStateTestSuite) TestRead() { s.NoError(err) got, err := s.AttestationState.Read(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), "wrong-passphrase") - s.ErrorContains(err, "incorrect passphrase") + s.ErrorContains(err, "created with a different token") + s.True(biz.IsErrInvalidPassphrase(err)) s.Nil(got) }) @@ -173,7 +174,8 @@ func (s *attestationStateTestSuite) TestRead() { s.NoError(err) got, err := s.AttestationState.Read(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.passphrase) - s.ErrorContains(err, "incorrect passphrase") + s.ErrorContains(err, "created with a different token") + s.True(biz.IsErrInvalidPassphrase(err)) s.Nil(got) }) } diff --git a/app/controlplane/pkg/biz/attestationstate_test.go b/app/controlplane/pkg/biz/attestationstate_test.go new file mode 100644 index 000000000..bd243d29d --- /dev/null +++ b/app/controlplane/pkg/biz/attestationstate_test.go @@ -0,0 +1,51 @@ +// +// Copyright 2024-2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecryptWrongPassphraseReturnsTypedError(t *testing.T) { + t.Parallel() + + ciphertext, err := encrypt([]byte("hello attestation"), "correct-passphrase") + require.NoError(t, err) + + // The passphrase that created the ciphertext still decrypts it. + plaintext, err := decrypt(ciphertext, "correct-passphrase") + require.NoError(t, err) + assert.Equal(t, []byte("hello attestation"), plaintext) + + // A different passphrase is reported as a typed error so the service layer + // can turn it into a client error instead of masking it as a server error. + _, err = decrypt(ciphertext, "another-passphrase") + require.Error(t, err) + assert.True(t, IsErrInvalidPassphrase(err), "expected ErrInvalidPassphrase, got %T: %v", err, err) +} + +// The read path wraps the decryption error with %w, so the typed error must +// still be discoverable through errors.As. +func TestErrInvalidPassphraseIsDiscoverableWhenWrapped(t *testing.T) { + t.Parallel() + + wrapped := fmt.Errorf("failed to decrypt attestation state: %w", NewErrInvalidPassphrase()) + assert.True(t, IsErrInvalidPassphrase(wrapped)) +} diff --git a/app/controlplane/pkg/biz/errors.go b/app/controlplane/pkg/biz/errors.go index 9216ce8ae..b6b2cdc54 100644 --- a/app/controlplane/pkg/biz/errors.go +++ b/app/controlplane/pkg/biz/errors.go @@ -200,3 +200,22 @@ func (e ErrAlreadyExists) Error() string { func IsErrAlreadyExists(err error) bool { return errors.As(err, &ErrAlreadyExists{}) } + +// ErrInvalidPassphrase is returned when the attestation state stored for a +// workflow run cannot be decrypted with the passphrase derived from the current +// request token. It typically means the state was created by a run that +// authenticated with a different token, for example after the API token was +// rotated. +type ErrInvalidPassphrase struct{} + +func NewErrInvalidPassphrase() ErrInvalidPassphrase { + return ErrInvalidPassphrase{} +} + +func (e ErrInvalidPassphrase) Error() string { + return "the attestation state was created with a different token, use the same token that ran 'attestation init', or reset the attestation and run 'attestation init' again" +} + +func IsErrInvalidPassphrase(err error) bool { + return errors.As(err, &ErrInvalidPassphrase{}) +}