Skip to content

Commit 751870b

Browse files
committed
fix(controlplane): surface attestation state passphrase mismatch as a client error
When a remote attestation is started with `attestation init` and later resumed (`attestation add`, `status` or `push`) with a different token, the stored state can no longer be decrypted: the encryption passphrase is derived from the request token. `decrypt` returned a plain `fmt.Errorf("incorrect passphrase")`, which `handleUseCaseErr` did not recognise, so it fell through to the default branch. The client received a masked internal server error and the failure was reported to Sentry, even though this is a client-side condition, not a server failure. Return a typed `biz.ErrInvalidPassphrase` from the biz layer and map it to `codes.FailedPrecondition` in the service layer. The message now tells the user to reuse the token that ran `attestation init`, or to reset the attestation and run `attestation init` again. Adds unit tests covering the typed decryption error and the service mapping, and updates the integration test that asserted on the old message. Refs #3571
1 parent 557449c commit 751870b

6 files changed

Lines changed: 91 additions & 3 deletions

File tree

‎app/controlplane/internal/service/service.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,11 @@ func handleUseCaseErr(ctx context.Context, err error, l *log.Helper) error {
543543
return status.Error(codes.AlreadyExists, err.Error())
544544
case biz.IsErrReleasedVersionImmutable(err):
545545
return status.Error(codes.FailedPrecondition, err.Error())
546+
case biz.IsErrInvalidPassphrase(err):
547+
// Client-side condition: the state was encrypted with a different token
548+
// than the one used to resume the attestation. Surface it as a known
549+
// error instead of masking it and reporting it to Sentry.
550+
return status.Error(codes.FailedPrecondition, err.Error())
546551
default:
547552
// Client errors already converted by this function can be processed again
548553
// (e.g. AttestationService.Store wraps storeAttestation, which converts internally).

‎app/controlplane/internal/service/service_test.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,15 @@ func TestHandleUseCaseErr(t *testing.T) {
133133
wantCode: codes.NotFound,
134134
wantMessage: "failed to find membership: organization chainloop not found",
135135
},
136+
{
137+
// An attestation resumed with a different token cannot be decrypted.
138+
// It is a client-side condition, so it must reach the user as a
139+
// FailedPrecondition naming the cause instead of a masked 500.
140+
name: "invalid passphrase maps to failed precondition",
141+
err: fmt.Errorf("failed to decrypt attestation state: %w", biz.NewErrInvalidPassphrase()),
142+
wantCode: codes.FailedPrecondition,
143+
wantMessage: "failed to decrypt attestation state: " + biz.NewErrInvalidPassphrase().Error(),
144+
},
136145
}
137146

138147
for _, tc := range testCases {

‎app/controlplane/pkg/biz/attestationstate.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,9 @@ func decrypt(ciphertext []byte, passphrase string) ([]byte, error) {
274274

275275
// Check if the decrypted data starts with the magic string
276276
if string(ciphertext[:len(magic)]) != magic {
277-
return nil, fmt.Errorf("incorrect passphrase")
277+
// A mismatch means the state was encrypted with a different passphrase,
278+
// i.e. a different request token, not a server-side failure.
279+
return nil, NewErrInvalidPassphrase()
278280
}
279281

280282
return ciphertext[len(magic):], nil

‎app/controlplane/pkg/biz/attestationstate_integration_test.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ func (s *attestationStateTestSuite) TestRead() {
160160
s.NoError(err)
161161

162162
got, err := s.AttestationState.Read(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), "wrong-passphrase")
163-
s.ErrorContains(err, "incorrect passphrase")
163+
s.ErrorContains(err, "created with a different token")
164+
s.True(biz.IsErrInvalidPassphrase(err))
164165
s.Nil(got)
165166
})
166167

@@ -173,7 +174,8 @@ func (s *attestationStateTestSuite) TestRead() {
173174
s.NoError(err)
174175

175176
got, err := s.AttestationState.Read(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.passphrase)
176-
s.ErrorContains(err, "incorrect passphrase")
177+
s.ErrorContains(err, "created with a different token")
178+
s.True(biz.IsErrInvalidPassphrase(err))
177179
s.Nil(got)
178180
})
179181
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//
2+
// Copyright 2024-2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package biz
17+
18+
import (
19+
"fmt"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
)
25+
26+
func TestDecryptWrongPassphraseReturnsTypedError(t *testing.T) {
27+
t.Parallel()
28+
29+
ciphertext, err := encrypt([]byte("hello attestation"), "correct-passphrase")
30+
require.NoError(t, err)
31+
32+
// The passphrase that created the ciphertext still decrypts it.
33+
plaintext, err := decrypt(ciphertext, "correct-passphrase")
34+
require.NoError(t, err)
35+
assert.Equal(t, []byte("hello attestation"), plaintext)
36+
37+
// A different passphrase is reported as a typed error so the service layer
38+
// can turn it into a client error instead of masking it as a server error.
39+
_, err = decrypt(ciphertext, "another-passphrase")
40+
require.Error(t, err)
41+
assert.True(t, IsErrInvalidPassphrase(err), "expected ErrInvalidPassphrase, got %T: %v", err, err)
42+
}
43+
44+
// The read path wraps the decryption error with %w, so the typed error must
45+
// still be discoverable through errors.As.
46+
func TestErrInvalidPassphraseIsDiscoverableWhenWrapped(t *testing.T) {
47+
t.Parallel()
48+
49+
wrapped := fmt.Errorf("failed to decrypt attestation state: %w", NewErrInvalidPassphrase())
50+
assert.True(t, IsErrInvalidPassphrase(wrapped))
51+
}

‎app/controlplane/pkg/biz/errors.go‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,22 @@ func (e ErrAlreadyExists) Error() string {
200200
func IsErrAlreadyExists(err error) bool {
201201
return errors.As(err, &ErrAlreadyExists{})
202202
}
203+
204+
// ErrInvalidPassphrase is returned when the attestation state stored for a
205+
// workflow run cannot be decrypted with the passphrase derived from the current
206+
// request token. It typically means the state was created by a run that
207+
// authenticated with a different token, for example after the API token was
208+
// rotated.
209+
type ErrInvalidPassphrase struct{}
210+
211+
func NewErrInvalidPassphrase() ErrInvalidPassphrase {
212+
return ErrInvalidPassphrase{}
213+
}
214+
215+
func (e ErrInvalidPassphrase) Error() string {
216+
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"
217+
}
218+
219+
func IsErrInvalidPassphrase(err error) bool {
220+
return errors.As(err, &ErrInvalidPassphrase{})
221+
}

0 commit comments

Comments
 (0)