Skip to content
Open
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")
}
}
35 changes: 34 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,35 @@ 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), 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 = module.ExportedFunction("se_last_error")
return inst, nil
}

Expand Down
18 changes: 15 additions & 3 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 @@ -459,6 +461,8 @@ func TestAutoUsesEnvironmentPresenceAndProfileExistence(t *testing.T) {
}
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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix in a follow-up: The malformed access key check at lines 457-460 does not check the Diagnostic code or look for the key text.

Impact: profile.Auto sends CS_CLIENT_ACCESS_KEY to the guest in the strategy config. The guest's create refuses a key that does not parse. This is the only guest call that receives an access key and fails before any HTTP request. If a later change puts the key text in that error, the text reaches Diagnostic.Message or Fields. No test fails.

Evidence:

  • The Rust leak test cannot run create, because the auth module exists only on wasm32 (auth/guest/src/lib.rs:101-102). So only this Go test runs the real code.
  • The check at lines 457-460 uses the value "not-a-key". It checks only errors.Is(err, ErrConfig).
  • In this PR, the malformed CRN check after it gets a wantCode call. The access key check does not.

Fix: Replace the check at lines 457-460 with the code below. The new code sets CS_CLIENT_ACCESS_KEY to a marker string and calls profile.Auto. It expects ErrConfig, code stack_auth::invalid_access_key, and no marker text in the error string or the Diagnostic.

Before you paste the code, compare it with lines 457-460:

  • The code assigns with err =. If err is not declared before this point, change it to err :=.
  • If the old check sets other environment variables, keep them.

The code also calls a helper wantDiagnostic(t, err, code). The helper must fail the test unless err contains a *Diagnostic with that code. It must return that *Diagnostic. If the auth tests have no helper with this name, add one next to wantCode.

	const marker = "leak-marker-access-key"
	t.Setenv("CS_CLIENT_ACCESS_KEY", marker)
	_, err = profile.Auto(context.Background())
	if !errors.Is(err, ErrConfig) {
		t.Fatalf("malformed access key: error = %v, want %v", err, ErrConfig)
	}
	d := wantDiagnostic(t, err, "stack_auth::invalid_access_key")
	if shown := fmt.Sprintf("%v %+v", err, *d); strings.Contains(shown, marker) {
		t.Fatalf("the access key is in the Diagnostic: %s", shown)
	}

Found by 1 model: claude

}
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 +591,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 +644,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,9 +668,16 @@ 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 := "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") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix in a follow-up: No Go test checks that the host's transport error text stays out of the auth Diagnostic.

Impact: When the RoundTripper returns an error, auth/transport.go:129 copies err.Error() into the guest. The guest puts that text in an io::Error inside RequestError (auth/guest/src/host.rs:41). A custom RoundTripper or a proxy error can put a URL with a query string, or proxy credentials, in that text. This PR now gives the guest's recorded error to Go callers. If the code that encodes that error starts to copy the io::Error message, the text reaches Diagnostic.Message or Causes. No test fails.

Evidence:

  • The Rust leak test cannot run host.rs, because that module exists only on wasm32 (auth/guest/src/lib.rs:101-104).
  • TestAuthTransportErrorNamesTheHTTPStatusNotTheBody checks only the body of a 403 response.
  • TestAuthTransportErrorWithoutAResponseNamesNoStatus checks only errors.Is and that the error has no "HTTP" text.

Fix: Add the test below next to TestAuthTransportErrorNamesTheHTTPStatusNotTheBody. Its RoundTripper returns an error whose text contains a marker string. The test expects ErrTransport, a *Diagnostic, and no marker text in the error string or in any part of the Diagnostic.

The test uses roundTripFunc, which this file already has. It also uses these names, and expects them to work like this:

  • guestOrSkip(t) skips the test when the guest is not available.
  • testCRN is a valid CRN for tests.
  • Open(ctx, dir, options...) opens a profile in dir.
  • WithRoundTripper(rt) makes the profile send its HTTP requests through rt.
  • WithBaseURL(url) sets the auth server URL for the strategy.

If the package uses different names for these jobs, change the test to use them.

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)
	}
}

Found by 1 model: claude

t.Fatalf("the response body is in the Diagnostic: %s", shown)
}
}

// A transport failure with no HTTP response at all stays the bare sentinel:
Expand Down
8 changes: 5 additions & 3 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 Down
38 changes: 34 additions & 4 deletions languages/golang/encrypt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,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.
- `ErrForbidden`, `ErrAuthentication`: a ciphertext that does not open under
Expand All @@ -201,9 +203,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()`
returns, `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
78 changes: 78 additions & 0 deletions languages/golang/encrypt/diagnostic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package encrypt

import (
"context"
"errors"
"testing"

"github.com/cipherstash/vitaminc/bindings/go/vcffi"
)

// 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)
}

// The guest's own refusals, which no ZeroKMS stub reaches: input that is
// not the codec, and an operation before init.
func TestGuestRefusalsCarryTheirDiagnostic(t *testing.T) {
ctx := context.Background()
c := rawInstance(t)
_, err := c.call(ctx, func(inst *instance) ([]byte, error) {
return inst.call(ctx, inst.planCheck, buf([]byte{0xff}))
})
if !errors.Is(err, ErrEncoding) {
t.Fatalf("plan check of bytes that are not the codec: %v, want ErrEncoding", err)
}
wantCode(t, err, "stack_guest_abi::malformed_input")

selector, err := vcffi.Marshal(KeysetName("tenant-b").selector())
if err != nil {
t.Fatal(err)
}
_, err = c.call(ctx, func(inst *instance) ([]byte, error) {
return inst.call(ctx, inst.keyset, buf(selector))
})
if !errors.Is(err, ErrState) {
t.Fatalf("a keyset before init: %v, want ErrState", err)
}
if d := wantDiagnostic(t, err, "stack_guest_abi::out_of_order"); d.Help == "" {
t.Error("an out-of-order call gives no help")
}
}

// A guest built before se_last_error fails as it always did: the bare
// sentinel, and nothing else.
func TestAGuestWithoutLastErrorFailsWithTheBareSentinel(t *testing.T) {
ctx := context.Background()
c := rawInstance(t)
c.inst.exports.LastError = nil
_, err := c.call(ctx, func(inst *instance) ([]byte, error) {
return inst.call(ctx, inst.planCheck, buf([]byte{0xff}))
})
if err != ErrEncoding {
t.Fatalf("err = %#v, want the bare ErrEncoding", err)
}
var d *Diagnostic
if errors.As(err, &d) {
t.Fatalf("a Diagnostic from a guest with no se_last_error: %+v", d)
}
}
4 changes: 3 additions & 1 deletion languages/golang/encrypt/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@
// network, or for stored data: [ErrForeignKeyset] when a *Cipher is given a
// record another keyset sealed, [ErrAuthentication] or [ErrForbidden] for a
// ciphertext that does not open under its field's context, [ErrEncoding] for
// a stored value that does not fit its declaration. Read them with errors.Is.
// a stored value that does not fit its declaration. Read the kind with
// errors.Is, and the detail behind a failure the engine reports (its code,
// help, structured fields and causes) with errors.As into a [*Diagnostic].
// No error, warning or log line holds a plaintext value; a generated type
// hides its sealed fields when a program prints it.
//
Expand Down
Loading