Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions languages/golang/auth/diagnostic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package auth

import (
"context"
"errors"
"os"
"path/filepath"
"testing"
)

// wantDiagnostic asserts that err carries a *Diagnostic with code and a
// message, and returns it.
func wantDiagnostic(t *testing.T, err error, code string) *Diagnostic {
t.Helper()
var d *Diagnostic
if !errors.As(err, &d) {
t.Fatalf("%v carries no Diagnostic", err)
}
if d.Code != code {
t.Fatalf("code = %q, want %q (%v)", d.Code, code, err)
}
if d.Message == "" {
t.Fatalf("%s has no message", code)
}
return d
}

// wantCode is wantDiagnostic for a test that needs only the code.
func wantCode(t *testing.T, err error, code string) {
t.Helper()
_ = wantDiagnostic(t, err, code)
}

// Each kind the profile store reports is its sentinel for errors.Is and a
// Diagnostic for errors.As. The strategies' kinds are asserted beside the
// tests that provoke them, in strategy_test.go. ErrInvalidFilename is not
// here: the store refuses every filename the guest would before asking it.
func TestEachStoreKindCarriesItsDiagnostic(t *testing.T) {
ctx := context.Background()
dir, s := profile(t)
ws, err := s.WorkspaceStore(ctx, wsB)
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
run func() error
want error
code string
}{
{"no current workspace", func() error {
_, err := s.CurrentWorkspace(ctx)
return err
}, ErrNoCurrentWorkspace, "stack_profile::no_current_workspace"},
{"a workspace with no directory", func() error {
return s.SetCurrentWorkspace(ctx, "CCCCCCCCCCCCCCCC")
}, ErrWorkspaceNotFound, "stack_profile::workspace_not_found"},
{"a workspace id that is a path", func() error {
return s.SetCurrentWorkspace(ctx, "../escape")
}, ErrInvalidWorkspaceID, "stack_profile::invalid_workspace_id"},
{"a file that is not there", func() error {
_, err := ws.Token(ctx)
return err
}, ErrNotFound, "stack_profile::not_found"},
{"a file that is not JSON", func() error {
write(t, filepath.Join(dir, "workspaces", wsB, "auth.json"), "{not json")
_, err := ws.Token(ctx)
return err
}, ErrInvalid, "stack_profile::json"},
{"a file that is a directory", func() error {
if err := os.MkdirAll(filepath.Join(dir, "workspaces", wsB, "secretkey.json"), 0o700); err != nil {
t.Fatal(err)
}
_, _, err := ws.SecretKey(ctx)
return err
}, ErrIO, "stack_profile::io"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.run()
if !errors.Is(err, tc.want) {
t.Fatalf("%v, want %v", err, tc.want)
}
wantCode(t, err, tc.code)
})
}
}

// A profile file that is not JSON says where: the line and column, never
// the parser's message, which could quote the file.
func TestAProfileJSONErrorNamesTheLine(t *testing.T) {
ctx := context.Background()
dir, s := profile(t)
write(t, filepath.Join(dir, "workspaces", wsB, "auth.json"), "{\n \"access_token\": 7\n}")
ws, err := s.WorkspaceStore(ctx, wsB)
if err != nil {
t.Fatal(err)
}
_, err = ws.Token(ctx)
d := wantDiagnostic(t, err, "stack_profile::json")
if d.Fields["line"] != uint64(2) {
t.Errorf("fields = %v, want line 2", d.Fields)
}
if d.Help == "" {
t.Error("a JSON error gives no help")
}
}
36 changes: 35 additions & 1 deletion languages/golang/auth/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import (
// Failure kinds the profile reports. The guest reports a status code from
// the one table every guest shares, so these are the sentinels of the
// shared decoder exposed under this package's names; an error from
// encrypt of the same kind is the same value.
// encrypt of the same kind is the same value. Check one with errors.Is;
// the detail behind it is a [Diagnostic].
var (
// ErrNotFound is a profile file that does not exist in the store asked:
// no secretkey.json, auth.json or device.json there. For the workspace's
Expand Down Expand Up @@ -62,3 +63,36 @@ var (
// logged in on this machine, or CS_CONFIG_PATH names the wrong place.
ErrNoProfile = errors.New("auth: no profile directory; run `stash auth login`")
)

// Diagnostic is the full error behind a failure the guest reports, beside
// the kind: every such failure is a *Diagnostic wrapping one of the
// sentinels above, so errors.Is matches the kind and errors.As reads the
// rest:
//
// var d *auth.Diagnostic
// if errors.As(err, &d) {
// log.Printf("%s: %s (%s)", d.Code, d.Message, d.Help)
// }
//
// Its fields are Code ("stack_profile::not_found", "stack_auth::invalid_crn",
// ...; stable), Message (what Error returns after the sentinel's text), Help,
// URL, Severity, Fields (the structured fields, by name: a profile file's
// "path", a JSON error's "line" and "column") and Causes (the errors behind
// it, outermost first).
//
// What one may carry is fixed: workspace ids, CRNs and regions, profile
// file paths, HTTP statuses and the auth server's error descriptions.
// Never a token, an access key, a client key or a response body.
//
// Errors the store raises itself carry none: a closed store ([ErrState]),
// a guest that did not return, [ErrMemoryLock], [ErrNoProfile]. A guest
// built before the detail existed returns the bare sentinel too.
//
// It is the same type as encrypt.Diagnostic, by identity.
type Diagnostic = guest.Diagnostic

// Cause is one error in a [Diagnostic]'s cause chain: a Code and a Message.
// A cause from a library outside the stack crates has no Code, and its
// Message is a description the guest vouches for, never that library's own
// text.
type Cause = guest.Cause
3 changes: 3 additions & 0 deletions languages/golang/auth/guest.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest.
return fail(fmt.Errorf("auth: guest is missing export %s", name))
}
}
// Optional: a guest built before se_last_error reports the status
// alone, and its failures stay the bare sentinels. See guest.Diagnostic.
inst.exports.LastError = guest.LastErrorExport(module)
return inst, nil
}

Expand Down
63 changes: 55 additions & 8 deletions languages/golang/auth/strategy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ func TestUsageLimitIsPreservedAcrossGuest(t *testing.T) {
if !errors.Is(err, ErrUsageLimit) {
t.Fatalf("Token error = %v, want %v", err, ErrUsageLimit)
}
wantCode(t, err, "stack_auth::usage_limit_exceeded")
}

func TestDeviceRefreshReportsInvalidClient(t *testing.T) {
Expand Down Expand Up @@ -278,6 +279,7 @@ func TestDeviceRefreshReportsInvalidClient(t *testing.T) {
if !errors.Is(err, ErrInvalidClient) {
t.Fatalf("Token error = %v, want %v", err, ErrInvalidClient)
}
wantCode(t, err, "stack_auth::invalid_client")
}

// Match stack-auth's AutoStrategy order: an access key wins over a stored
Expand Down Expand Up @@ -452,13 +454,20 @@ func TestAutoUsesEnvironmentPresenceAndProfileExistence(t *testing.T) {
t.Fatalf("set but empty access key: error = %v, want %v", err, ErrConfig)
}
// A key that does not parse is a configuration error like the empty one,
// the class Rust's AutoStrategy reports, not a malformed-input error.
t.Setenv("CS_CLIENT_ACCESS_KEY", "not-a-key")
// the class Rust's AutoStrategy reports, not a malformed-input error. It
// is the one guest call that receives an access key and fails before
// any request, and its error never quotes the key.
const keyMarker = "leak-marker-access-key"
t.Setenv("CS_CLIENT_ACCESS_KEY", keyMarker)
if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrConfig) {
t.Fatalf("malformed access key: error = %v, want %v", err, ErrConfig)
} else if d := wantDiagnostic(t, err, "stack_auth::invalid_access_key"); strings.Contains(fmt.Sprintf("%v %+v", err, *d), keyMarker) {
t.Fatalf("the access key is in the Diagnostic: %v %+v", err, *d)
}
if _, err := profile.AccessKey(context.Background(), "invalid", "CSAKtestKeyId.testKeySecret"); !errors.Is(err, ErrConfig) {
t.Fatalf("malformed CRN for access key: error = %v, want %v", err, ErrConfig)
} else {
wantCode(t, err, "stack_auth::invalid_crn")
Comment thread
coderdan marked this conversation as resolved.
}
provider := OIDCProviderFunc(func(context.Context) (string, error) { return "", nil })
if _, err := profile.OIDC(context.Background(), "invalid", provider); !errors.Is(err, ErrConfig) {
Expand Down Expand Up @@ -587,6 +596,7 @@ func TestDeviceRefreshReportsInvalidGrant(t *testing.T) {
if !errors.Is(err, ErrInvalidGrant) {
t.Fatalf("Token error = %v, want ErrInvalidGrant", err)
}
wantCode(t, err, "stack_auth::invalid_grant")
}

// The edge in front of production CTS answers a request whose User-Agent is
Expand Down Expand Up @@ -639,8 +649,8 @@ func isStackAuthGoAgent(ua string) bool {
return ok && version != "" && !strings.ContainsAny(version, " ()")
}

// Only a status code crosses the guest ABI, so a refused exchange must still
// say which HTTP status refused it, and never carry the response body.
// A refused exchange says which HTTP status refused it, and never carries
// the response body: not in the message, and not in the Diagnostic.
func TestAuthTransportErrorNamesTheHTTPStatusNotTheBody(t *testing.T) {
guestOrSkip(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -663,13 +673,20 @@ func TestAuthTransportErrorNamesTheHTTPStatusNotTheBody(t *testing.T) {
if !errors.Is(err, ErrTransport) {
t.Fatalf("Token error = %v, want ErrTransport", err)
}
if want := "cipherstash: auth transport failed: HTTP 403"; err.Error() != want {
if want := "cipherstash: auth transport failed: Server error: 403: HTTP 403"; err.Error() != want {
t.Fatalf("Token error = %q, want %q", err, want)
}
var d *Diagnostic
if !errors.As(err, &d) || d.Code != "stack_auth::server_error" {
t.Fatalf("Token error = %#v, want a stack_auth::server_error Diagnostic", err)
}
if shown := fmt.Sprintf("%+v", *d); strings.Contains(shown, "nginx") || strings.Contains(shown, "testKeySecret") {
Comment thread
coderdan marked this conversation as resolved.
t.Fatalf("the response body is in the Diagnostic: %s", shown)
}
}

// A transport failure with no HTTP response at all stays the bare sentinel:
// there is no status to name.
// A transport failure with no HTTP response at all names no status: there
// is none to name. It is the guest's request error, over ErrTransport.
func TestAuthTransportErrorWithoutAResponseNamesNoStatus(t *testing.T) {
guestOrSkip(t)
server := httptest.NewServer(http.NotFoundHandler())
Expand All @@ -687,7 +704,37 @@ func TestAuthTransportErrorWithoutAResponseNamesNoStatus(t *testing.T) {
defer strategy.Close()
_, err = strategy.Token(context.Background())
if !errors.Is(err, ErrTransport) || strings.Contains(err.Error(), "HTTP") {
t.Fatalf("Token error = %v, want a bare ErrTransport", err)
t.Fatalf("Token error = %v, want ErrTransport naming no status", err)
}
wantCode(t, err, "stack_auth::request_error")
}

// The host's transport error stays out of the Diagnostic: a RoundTripper's
// or a proxy's text can carry a URL's query string or proxy credentials.
func TestAuthTransportErrorTextIsNotInTheDiagnostic(t *testing.T) {
guestOrSkip(t)
ctx := context.Background()
const marker = "leak-marker-transport"
rt := roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New(`Post "https://cts.invalid/token?secret=` + marker + `": proxyconnect tcp: refused`)
})
profile, err := Open(ctx, t.TempDir(), WithRoundTripper(rt))
if err != nil {
t.Fatal(err)
}
defer profile.Close()
strategy, err := profile.AccessKey(ctx, testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL("https://cts.invalid"))
if err != nil {
t.Fatal(err)
}
defer strategy.Close()
_, err = strategy.Token(ctx)
var d *Diagnostic
if !errors.Is(err, ErrTransport) || !errors.As(err, &d) {
t.Fatalf("Token error = %#v, want a Diagnostic over ErrTransport", err)
}
if shown := fmt.Sprintf("%v %+v", err, *d); strings.Contains(shown, marker) {
t.Fatalf("the host's transport error is in the Diagnostic: %s", shown)
}
}

Expand Down
13 changes: 8 additions & 5 deletions languages/golang/auth/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@ func (f OIDCProviderFunc) Token(ctx context.Context) (string, error) { return f(
const maxAuthResponseBytes = 16 << 20

// authHTTPStatus records the status of the last HTTP response the transport
// received during one guest call. Only a status code crosses the guest ABI,
// so without it a refused exchange (the edge in front of CTS answering 403)
// reaches the caller as a bare ErrTransport. It lives on the call's
// received during one guest call. The guest's error does not always name it
// (a refused response whose body could not be read reaches the guest as a
// transport failure, with no status), and a guest built before se_last_error
// gives no error at all, so without it a refused exchange (the edge in front
// of CTS answering 403) could reach the caller as a bare ErrTransport. It lives on the call's
// context, which wazero hands to the host import, so concurrent calls on
// different profiles never see each other's status.
type authHTTPStatus struct{ code int }
Expand All @@ -57,8 +59,9 @@ func withAuthHTTPStatus(ctx context.Context) (context.Context, *authHTTPStatus)
}

// wrap names the HTTP status of a refused exchange on an ErrTransport
// ("cipherstash: auth transport failed: HTTP 403"). The body is never
// included: it may be an HTML error page, or echo a credential.
// ("cipherstash: auth transport failed: Server error: 403: HTTP 403", the
// guest's Diagnostic then the status). The body is never included: it may
// be an HTML error page, or echo a credential.
func (s *authHTTPStatus) wrap(err error) error {
if err == nil || !errors.Is(err, ErrTransport) || s.code == 0 || (s.code >= 200 && s.code < 300) {
return err
Expand Down
38 changes: 34 additions & 4 deletions languages/golang/encrypt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ A database can compare some terms by itself:

The generator and the compiler find a mistake in a declaration, so no call
returns an error for one. A call returns an error for a key, for the network,
or for stored data; read them with `errors.Is`:
or for stored data. Check an error two ways.

`errors.Is` tells you the kind, which is what a program branches on:

- `ErrForeignKeyset`: a `*Cipher` got a row another keyset sealed.
- `ErrContextMismatch`: a `*Cipher` with a `Context` got a row whose
Expand All @@ -226,9 +228,37 @@ or for stored data; read them with `errors.Is`:
- `ErrUnauthorized`, `ErrNotFound`, `ErrTransport`, `ErrKMS`: ZeroKMS.
- `ErrState`: a call on a closed client. `ErrMemoryLock`: see below.

No error, warning or log line holds a plaintext value. A generated type hides
its sealed fields when a program prints or logs it; the struct you wrote does
not, and `stashgen -redact` writes `String` and `LogValue` for it.
`errors.As` with a `*Diagnostic` gives you the detail behind a failure the
engine reports: a stable `Code` (`stack_encrypt::foreign_keyset`,
`stack_kms::keyset_not_found`, ...), the one-line `Message` that `Error()`
Comment thread
coderdan marked this conversation as resolved.
returns after the kind's text, `Help` saying what to do about it, `Fields` (structured values, by
name) and `Causes` (the errors behind it). Accessors read the values a program
is likely to branch on:

```go
var d *encrypt.Diagnostic
if errors.As(err, &d) {
log.Printf("%s: %s (%s)", d.Code, d.Message, d.Help)
if expected, ok := d.ExpectedKeyset(); ok {
found, _ := d.FoundKeyset()
log.Printf("cipher is bound to %s; the row was sealed under %s",
encrypt.KeysetID(expected), encrypt.KeysetID(found))
}
}
```

`Field` and `Reason` name the field and the problem (`field_missing`,
`unknown_key`, ...) on a refused plan, record or value. An error the client
raises itself carries no `Diagnostic`: a closed client, a guest that did not
return, `ErrMemoryLock`, and an argument refused before it reached the engine.

What an error may contain is fixed. It may carry keyset ids and names, field
names, counts, index kinds, ZeroKMS request kinds and HTTP statuses. It never
carries a plaintext value, key material, a token, ciphertext or term bytes, or
the values of an encryption context. No warning or log line holds a plaintext
value either. A generated type hides its sealed fields when a program prints
or logs it; the struct you wrote does not, and `stashgen -redact` writes
`String` and `LogValue` for it.

## Key material

Expand Down
5 changes: 3 additions & 2 deletions languages/golang/encrypt/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ func (k *Checker) Close() error { return k.c.Close() }

// Check refuses a plan the engine would refuse: an index its field's type
// does not admit, a context that is not a label, two fields under one
// identity. The error is [ErrEncoding]; which rule failed is the engine's to
// know, so a caller that wants the field named checks one field at a time.
// identity. The error is [ErrEncoding]. A refusal from the engine is a
// [*Diagnostic] that names the field with [Diagnostic.Field] and the rule
// with [Diagnostic.Reason].
func (k *Checker) Check(ctx context.Context, plan *record.Plan) error {
if err := plan.Validate(); err != nil {
return fmt.Errorf("%w: %v", ErrEncoding, err)
Expand Down
11 changes: 9 additions & 2 deletions languages/golang/encrypt/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,9 +427,16 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([
err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", guest.MemoryLockError(g.Reason), err)
}
// The guest reports a failed token_get as a transport failure and no
// more; the token source said why.
// more; the token source said why. When the source's error is a
// Diagnostic it goes first, so errors.As finds the cause rather than the
// guest's "token_get failed"; errors.Is still matches both.
if err != nil && c.transport != nil && c.transport.tokenErr != nil {
err = fmt.Errorf("%w (token source: %w)", err, c.transport.tokenErr)
var cause *guest.Diagnostic
if errors.As(c.transport.tokenErr, &cause) {
err = fmt.Errorf("token source: %w (%w)", c.transport.tokenErr, err)
} else {
err = fmt.Errorf("%w (token source: %w)", err, c.transport.tokenErr)
}
}
if err != nil {
return nil, err
Expand Down
Loading
Loading