diff --git a/languages/golang/auth/diagnostic_test.go b/languages/golang/auth/diagnostic_test.go new file mode 100644 index 000000000..d2611caee --- /dev/null +++ b/languages/golang/auth/diagnostic_test.go @@ -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") + } +} diff --git a/languages/golang/auth/errors.go b/languages/golang/auth/errors.go index 72eeba7a9..72fc2a7d0 100644 --- a/languages/golang/auth/errors.go +++ b/languages/golang/auth/errors.go @@ -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 @@ -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 diff --git a/languages/golang/auth/guest.go b/languages/golang/auth/guest.go index 2d157f5b0..5c17ef261 100644 --- a/languages/golang/auth/guest.go +++ b/languages/golang/auth/guest.go @@ -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 } diff --git a/languages/golang/auth/strategy_test.go b/languages/golang/auth/strategy_test.go index 3715ea115..957bb53a6 100644 --- a/languages/golang/auth/strategy_test.go +++ b/languages/golang/auth/strategy_test.go @@ -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) { @@ -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 @@ -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") } provider := OIDCProviderFunc(func(context.Context) (string, error) { return "", nil }) if _, err := profile.OIDC(context.Background(), "invalid", provider); !errors.Is(err, ErrConfig) { @@ -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 @@ -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) { @@ -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") { + 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()) @@ -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) } } diff --git a/languages/golang/auth/transport.go b/languages/golang/auth/transport.go index d2cea0f65..599f40dab 100644 --- a/languages/golang/auth/transport.go +++ b/languages/golang/auth/transport.go @@ -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 } @@ -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 diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md index e2a36e8a5..bc4a3e155 100644 --- a/languages/golang/encrypt/README.md +++ b/languages/golang/encrypt/README.md @@ -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 @@ -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()` +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 diff --git a/languages/golang/encrypt/checker.go b/languages/golang/encrypt/checker.go index 7cd2cc5a3..7f3a99e25 100644 --- a/languages/golang/encrypt/checker.go +++ b/languages/golang/encrypt/checker.go @@ -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) diff --git a/languages/golang/encrypt/client.go b/languages/golang/encrypt/client.go index 361106ada..4f89bd4ff 100644 --- a/languages/golang/encrypt/client.go +++ b/languages/golang/encrypt/client.go @@ -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 diff --git a/languages/golang/encrypt/diagnostic_test.go b/languages/golang/encrypt/diagnostic_test.go new file mode 100644 index 000000000..811496d44 --- /dev/null +++ b/languages/golang/encrypt/diagnostic_test.go @@ -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) + } +} diff --git a/languages/golang/encrypt/doc.go b/languages/golang/encrypt/doc.go index 71d4de4b0..7b114f0e3 100644 --- a/languages/golang/encrypt/doc.go +++ b/languages/golang/encrypt/doc.go @@ -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. // diff --git a/languages/golang/encrypt/errors.go b/languages/golang/encrypt/errors.go index 1445c2d1d..0a21d54af 100644 --- a/languages/golang/encrypt/errors.go +++ b/languages/golang/encrypt/errors.go @@ -2,10 +2,11 @@ package encrypt import "github.com/cipherstash/stack/languages/golang/internal/guest" -// Failure kinds surfaced across the boundary. The guest reports a status -// code and nothing else, so these are the whole vocabulary: they separate a -// tampered ciphertext from a bad token from a malformed input, and reveal -// nothing about plaintext or key material. +// Failure kinds surfaced across the boundary: the guest reports a status +// code, and these are what the codes decode to. They separate a tampered +// ciphertext from a bad token from a malformed input, and reveal nothing +// about plaintext or key material. Check one with errors.Is; the detail +// behind it is a [Diagnostic]. // // They are the sentinels every guest package shares (one status table for // every guest, decoded once), exposed here under this package's names: an @@ -64,3 +65,40 @@ var ( // (ulimit -l, a systemd LimitMEMLOCK=, or a pod's securityContext). ErrMemoryLock = guest.ErrMemoryLock ) + +// 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 *encrypt.Diagnostic +// if errors.As(err, &d) { +// log.Printf("%s: %s (%s)", d.Code, d.Message, d.Help) +// } +// +// Its fields are Code ("stack_encrypt::foreign_keyset", +// "stack_kms::keyset_not_found", ...; stable), Message (what Error +// returns after the sentinel's text), Help, URL, Severity, Fields (the +// structured fields, by name) and Causes (the errors behind it, outermost +// first). Accessors read the fields a caller branches on: ExpectedKeyset +// and FoundKeyset on an [ErrForeignKeyset] (each a [KeysetID]'s bytes), and +// Field and Reason on a refused plan, record or value. +// +// What one may carry is fixed: keyset ids and names, field names, counts, +// index kinds, ZeroKMS request kinds and HTTP statuses. Never plaintext, +// key material, tokens, ciphertext or term bytes, or the values of an +// encryption context. +// +// Errors the client raises itself carry none: a closed client ([ErrState]), +// a guest that did not return, [ErrMemoryLock], and an argument refused +// before it reached the guest. A guest built before the detail existed +// returns the bare sentinel too. +// +// It is the same type as auth.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 diff --git a/languages/golang/encrypt/errors_test.go b/languages/golang/encrypt/errors_test.go new file mode 100644 index 000000000..e7253d348 --- /dev/null +++ b/languages/golang/encrypt/errors_test.go @@ -0,0 +1,97 @@ +package encrypt_test + +import ( + "context" + "errors" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/internal/testusers" +) + +// Each kind the engine refuses through the public API is its sentinel for +// errors.Is and a Diagnostic for errors.As. The ZeroKMS kinds are in +// TestTransportOutcomesMapToErrors, and the guest's own refusals in +// TestGuestRefusalsCarryTheirDiagnostic. +func TestEachKindCarriesItsDiagnostic(t *testing.T) { + c := deterministicClient(t) + ctx := context.Background() + cipher := c.DefaultKeyset() + elsewhere, err := testusers.Encrypt(ctx, c.Keyset(encrypt.KeysetName("tenant-b")), people[:1]) + if err != nil { + t.Fatal(err) + } + flipped, err := testusers.Encrypt(ctx, cipher, people[:1]) + if err != nil { + t.Fatal(err) + } + flipped[0].Email.Ciphertext[len(flipped[0].Email.Ciphertext)-1] ^= 1 + truncated, err := testusers.Encrypt(ctx, cipher, people[:1]) + if err != nil { + t.Fatal(err) + } + truncated[0].Email.Ciphertext = truncated[0].Email.Ciphertext[:3] + cases := []struct { + name string + run func() error + want error + code string + }{ + {"a row sealed under another keyset", func() error { + _, err := testusers.Decrypt(ctx, cipher, elsewhere) + return err + }, encrypt.ErrForeignKeyset, "stack_encrypt::foreign_keyset"}, + {"a tampered leaf", func() error { + _, err := testusers.Decrypt(ctx, cipher, flipped) + return err + }, encrypt.ErrAuthentication, "stack_encrypt::aead"}, + {"match text with no token", func() error { + _, err := testusers.Fields.Email.Match(ctx, cipher, "") + return err + }, encrypt.ErrTerm, "stack_encrypt::empty_term_text"}, + {"a ciphertext that is not a sealed value", func() error { + _, err := testusers.Decrypt(ctx, cipher, truncated) + return err + }, encrypt.ErrEncoding, "stack_encrypt::leaf_truncated"}, + } + 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) + } + encrypt.WantCode(t, err, tc.code) + }) + } +} + +// A foreign-keyset refusal names both keysets: the one the cipher is bound +// to and the one the row was sealed under. +func TestAForeignKeysetNamesBothKeysets(t *testing.T) { + c := deterministicClient(t) + ctx := context.Background() + other := c.Keyset(encrypt.KeysetName("tenant-b")) + encrypted, err := testusers.Encrypt(ctx, other, people[:1]) + if err != nil { + t.Fatal(err) + } + _, err = testusers.Decrypt(ctx, c.DefaultKeyset(), encrypted) + d := encrypt.WantDiagnostic(t, err, "stack_encrypt::foreign_keyset") + wantExpected, err := c.DefaultKeyset().KeysetID(ctx) + if err != nil { + t.Fatal(err) + } + wantFound, err := other.KeysetID(ctx) + if err != nil { + t.Fatal(err) + } + if got, ok := d.ExpectedKeyset(); !ok || got != wantExpected { + t.Errorf("ExpectedKeyset() = %x, %v; want %s", got, ok, wantExpected) + } + if got, ok := d.FoundKeyset(); !ok || got != wantFound { + t.Errorf("FoundKeyset() = %x, %v; want %s", got, ok, wantFound) + } + if d.Help == "" { + t.Error("a foreign keyset gives no help") + } +} diff --git a/languages/golang/encrypt/export_test.go b/languages/golang/encrypt/export_test.go index 40e74cd4d..8fd779c95 100644 --- a/languages/golang/encrypt/export_test.go +++ b/languages/golang/encrypt/export_test.go @@ -176,3 +176,10 @@ func NewCheckerOver(ctx context.Context, wasm []byte) (*Checker, error) { // LiveClient is liveClient for the external tests: a client against real // ZeroKMS from the STACK_ENCRYPT_TEST_* variables, or a skip. func LiveClient(t *testing.T) *Client { return liveClient(t) } + +// WantDiagnostic asserts that err carries a *Diagnostic with code and a +// message, and returns it; WantCode only asserts. +var ( + WantDiagnostic = wantDiagnostic + WantCode = wantCode +) diff --git a/languages/golang/encrypt/guest.go b/languages/golang/encrypt/guest.go index cddc1bade..c5bbecbd4 100644 --- a/languages/golang/encrypt/guest.go +++ b/languages/golang/encrypt/guest.go @@ -183,6 +183,9 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy guest.Lo return nil, fmt.Errorf("encrypt: 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 } diff --git a/languages/golang/encrypt/guest_test.go b/languages/golang/encrypt/guest_test.go index 6d9b1153a..f6863f62a 100644 --- a/languages/golang/encrypt/guest_test.go +++ b/languages/golang/encrypt/guest_test.go @@ -179,20 +179,24 @@ func TestNewClientIssuesTheLoadKeysetRequest(t *testing.T) { func TestTransportOutcomesMapToErrors(t *testing.T) { guestOrSkip(t) + // Each outcome is its sentinel for errors.Is and a Diagnostic for + // errors.As, whose code names the refusal and which never carries + // ZeroKMS's response body. cases := []struct { name string status int contentType string body string want error + code string }{ - {"401", http.StatusUnauthorized, "", "nope", ErrUnauthorized}, - {"403", http.StatusForbidden, "", "not permitted", ErrForbidden}, - {"404", http.StatusNotFound, "", "missing", ErrNotFound}, - {"409", http.StatusConflict, "", "exists", ErrConflict}, - {"500", http.StatusInternalServerError, "", "boom", ErrKMS}, - {"200 html", http.StatusOK, "text/html", "gateway", ErrKMS}, - {"200 not json", http.StatusOK, "application/json", "not json", ErrKMS}, + {"401", http.StatusUnauthorized, "", "nope", ErrUnauthorized, "stack_kms::load_keyset_unauthorized"}, + {"403", http.StatusForbidden, "", "not permitted", ErrForbidden, "stack_kms::load_keyset_forbidden"}, + {"404", http.StatusNotFound, "", "missing", ErrNotFound, "stack_kms::keyset_not_found"}, + {"409", http.StatusConflict, "", "exists", ErrConflict, "stack_kms::load_keyset_failed"}, + {"500", http.StatusInternalServerError, "", "boom", ErrKMS, "stack_kms::load_keyset_failed"}, + {"200 html", http.StatusOK, "text/html", "gateway", ErrKMS, "stack_kms::load_keyset_failed"}, + {"200 not json", http.StatusOK, "application/json", "not json", ErrKMS, "stack_kms::load_keyset_failed"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -201,6 +205,13 @@ func TestTransportOutcomesMapToErrors(t *testing.T) { if !errors.Is(err, tc.want) { t.Fatalf("NewClient: %v, want %v", err, tc.want) } + d := wantDiagnostic(t, err, tc.code) + if d.Fields["request_kind"] == nil { + t.Errorf("fields = %v, want the request kind", d.Fields) + } + if shown := fmt.Sprintf("%s %v %v %s", d.Message, d.Fields, d.Causes, d.Help); strings.Contains(shown, tc.body) { + t.Errorf("the response body is in the detail: %s", shown) + } }) } t.Run("connection refused", func(t *testing.T) { @@ -211,6 +222,9 @@ func TestTransportOutcomesMapToErrors(t *testing.T) { if !errors.Is(err, ErrTransport) { t.Fatalf("NewClient: %v, want ErrTransport", err) } + if d := wantDiagnostic(t, err, "stack_kms::load_keyset_failed"); d.Fields["request_kind"] != "SendRequest" { + t.Errorf("fields = %v, want request_kind SendRequest", d.Fields) + } }) t.Run("no token", func(t *testing.T) { stub := newStub(t, http.StatusOK, "application/json", "{}") @@ -230,6 +244,22 @@ func TestTransportOutcomesMapToErrors(t *testing.T) { t.Fatalf("a request was made without a token: %+v", stub.requests) } }) + t.Run("a token source that says why", func(t *testing.T) { + stub := newStub(t, http.StatusOK, "application/json", "{}") + cfg := testConfig(stub.URL) + // An auth strategy's refusal, as the auth package reports one. + refused := &Diagnostic{Code: "stack_auth::invalid_grant", Message: "Invalid grant", Help: "Log in again."} + cfg = append(cfg, WithCredentials(testCredentials(tokenFunc(func(context.Context) (string, error) { return "", refused })))) + _, err := NewClient(context.Background(), cfg...) + // errors.As finds the token source's detail, not the guest's + // "token_get failed", and errors.Is still matches both. + if d := wantDiagnostic(t, err, "stack_auth::invalid_grant"); d.Help != "Log in again." { + t.Errorf("Help = %q", d.Help) + } + if !errors.Is(err, ErrTransport) || !errors.Is(err, refused) { + t.Errorf("NewClient: %v, want it to match ErrTransport and the token source's error", err) + } + }) } func TestRoundTripperFailureIsTransport(t *testing.T) { @@ -757,12 +787,15 @@ func TestCheckerRefusesAnIndexedUntypedField(t *testing.T) { t.Errorf("%s: err = %v, want ErrEncoding naming the field", name, err) } // Past it, the engine refuses the same plan (Error::UntypedIndex in - // stack-encrypt) and the guest reports it as the caller's input. The - // ABI carries a status word and no message, so the field's name is - // the Go rule's to give; this asserts the two rules agree. - if err := checker.engineCheck(ctx, p); !errors.Is(err, ErrEncoding) { + // stack-encrypt) and the guest reports it as the caller's input, + // naming the same field: the two rules agree. + err = checker.engineCheck(ctx, p) + if !errors.Is(err, ErrEncoding) { t.Errorf("%s past Validate: err = %v, want ErrEncoding from the engine", name, err) } + if d := wantDiagnostic(t, err, "stack_encrypt::dynamic_untyped_index"); d.Field() != "age" { + t.Errorf("%s past Validate: the engine named field %q, want \"age\"", name, d.Field()) + } } sealed := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "notes", Kind: record.Untyped, Outputs: []record.Output{record.Ciphertext}}}} if err := checker.Check(ctx, sealed); err != nil { @@ -770,6 +803,29 @@ func TestCheckerRefusesAnIndexedUntypedField(t *testing.T) { } } +// The engine's reason reaches Go under the key Reason reads: a field whose +// outputs name the ciphertext twice is refused as a duplicate output. +func TestAPlanRefusalNamesItsReason(t *testing.T) { + guestOrSkip(t) + ctx := context.Background() + checker, err := NewChecker(ctx) + if err != nil { + t.Fatal(err) + } + defer checker.Close() + p := &record.Plan{Context: []string{"users"}, Fields: []record.Field{ + {Name: "age", Kind: record.Uint32, Outputs: []record.Output{record.Ciphertext, record.Ciphertext}}, + }} + err = checker.engineCheck(ctx, p) + if !errors.Is(err, ErrEncoding) { + t.Fatalf("err = %v, want ErrEncoding", err) + } + d := wantDiagnostic(t, err, "stack_encrypt::dynamic_plan") + if d.Field() != "age" || d.Reason() != "duplicate_output" { + t.Errorf("Field() = %q, Reason() = %q", d.Field(), d.Reason()) + } +} + // se_plan_check answers with no cipher: a plan the engine runs passes, a // plan it refuses is ErrEncoding, never ErrState. func TestPlanCheckAnswersWithoutACipher(t *testing.T) { diff --git a/languages/golang/encrypt/live_test.go b/languages/golang/encrypt/live_test.go index b479240c4..8c0b3a406 100644 --- a/languages/golang/encrypt/live_test.go +++ b/languages/golang/encrypt/live_test.go @@ -106,12 +106,30 @@ func TestLiveForeignKeysetIsRefusedBeforeRetrieval(t *testing.T) { t.Fatal(err) } encrypt.ResetSends(c) - if _, err := testusers.DecryptDocument(ctx, c.DefaultKeyset(), encrypted); !errors.Is(err, encrypt.ErrForeignKeyset) { + _, err = testusers.DecryptDocument(ctx, c.DefaultKeyset(), encrypted) + if !errors.Is(err, encrypt.ErrForeignKeyset) { t.Fatalf("default cipher opened another keyset's row: %v", err) } if n := encrypt.Sends(c); n != 0 { t.Errorf("a foreign row cost %d ZeroKMS calls before refusal", n) } + // The refusal names both keysets: the default the cipher is bound to, + // and the one the row was sealed under. + d := encrypt.WantDiagnostic(t, err, "stack_encrypt::foreign_keyset") + wantExpected, err := c.DefaultKeyset().KeysetID(ctx) + if err != nil { + t.Fatal(err) + } + wantFound, err := c.Keyset(encrypt.KeysetName(other)).KeysetID(ctx) + if err != nil { + t.Fatal(err) + } + if got, ok := d.ExpectedKeyset(); !ok || got != wantExpected { + t.Errorf("ExpectedKeyset() = %x, %v; want %s", got, ok, wantExpected) + } + if got, ok := d.FoundKeyset(); !ok || got != wantFound { + t.Errorf("FoundKeyset() = %x, %v; want %s", got, ok, wantFound) + } if docs, err := testusers.DecryptDocument(ctx, c, encrypted); err != nil || docs[0].Title != "tenant b" { t.Fatalf("client decrypt of the other keyset: %v %+v", err, docs) } diff --git a/languages/golang/encrypt/records.go b/languages/golang/encrypt/records.go index d6ceea7ac..424184526 100644 --- a/languages/golang/encrypt/records.go +++ b/languages/golang/encrypt/records.go @@ -468,9 +468,9 @@ func (c *Client) open(ctx context.Context, sel KeysetSelector, context string, p } // locateTermFailure names the row, field and index behind an ErrTerm from a -// record call. The guest reports a status and nothing else, so the host asks -// the engine again, one term at a time, which costs no key request: a term -// derives locally. The engine defines no match term for text that yields no +// record call. The guest's error says what was wrong with the term but not +// which value or field it came from, so the host asks the engine again, one +// term at a time, which costs no key request: a term derives locally. The engine defines no match term for text that yields no // token (empty, separator-only, or shorter than the n-gram), because an // empty term would match every row; a program hands such a field a value or // drops the match index. p is the plan without this cipher's extension, diff --git a/languages/golang/internal/guest/call.go b/languages/golang/internal/guest/call.go index 0d624af76..0c0c48d4a 100644 --- a/languages/golang/internal/guest/call.go +++ b/languages/golang/internal/guest/call.go @@ -41,9 +41,13 @@ func BufArg(data []byte) Arg { return Arg{data: data, isBuf: true} } // ScalarArg is a scalar argument. func ScalarArg(v uint64) Arg { return Arg{scalar: v} } -// Exports is the pair of exports every guest has, resolved on one module. +// Exports is the exports every guest package drives, resolved on one module. type Exports struct { Alloc, Dealloc api.Function + // LastError is se_last_error: the full error behind the last failed + // export (see Diagnostic). Nil for a guest built before it, which + // reports the status alone. + LastError api.Function } // AllocWrite stages data into a fresh guest buffer. @@ -81,7 +85,9 @@ func (e Exports) Free(ctx context.Context, b Buf) { // Call stages every buffer argument, calls fn with the arguments in // order, and copies the output out before every buffer — inputs and // output — is wiped and freed. mem is the module's allocator, held -// mapped for the whole call. +// mapped for the whole call. A failure the guest reports comes back as a +// *Diagnostic wrapping its status's sentinel, or as the sentinel alone +// when the guest gives no detail. func Call(ctx context.Context, mem *Allocator, m api.Module, e Exports, fn api.Function, args ...Arg) ([]byte, error) { mem.Enter() defer mem.Exit() @@ -110,7 +116,7 @@ func Call(ctx context.Context, mem *Allocator, m api.Module, e Exports, fn api.F } ptr, n, err := PackedResult(res[0]) if err != nil { - return nil, err + return nil, e.diagnose(ctx, m, err) } out := Buf{Ptr: ptr, Len: n} bufs = append(bufs, out) diff --git a/languages/golang/internal/guest/diagnostic.go b/languages/golang/internal/guest/diagnostic.go new file mode 100644 index 000000000..603fd6277 --- /dev/null +++ b/languages/golang/internal/guest/diagnostic.go @@ -0,0 +1,280 @@ +package guest + +import ( + "context" + "encoding/hex" + "fmt" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" + "github.com/tetratelabs/wazero/api" +) + +// Diagnostic is the full error behind a guest's status: the error the Rust +// code raised, as the guest's se_last_error hands it over. Every failure a +// guest reports comes back as one, wrapping the sentinel its status maps +// to, so a caller checks the kind with errors.Is and reads the detail with +// errors.As: +// +// if errors.Is(err, encrypt.ErrForeignKeyset) { ... } +// var d *encrypt.Diagnostic +// if errors.As(err, &d) { log.Print(d.Code, d.Help) } +// +// What it may carry is the rule written on stack-profile's ErrorPayload: +// keyset ids and names, field names, counts, index kinds, ZeroKMS request +// kinds and HTTP statuses, workspace ids, CRNs and regions, profile file +// paths. Never plaintext, key material, tokens, ciphertext or term bytes, +// or a context's values: a stored context's length and parts at most. +// +// A guest built before se_last_error, or one whose error does not decode, +// returns the bare sentinel instead: the detail is never allowed to hide +// the failure. +// +// Only a guest makes a Diagnostic that unwraps to a sentinel. A fake in a +// caller's test that needs both wraps the two together, and errors.Is and +// errors.As each find theirs: +// +// d := &encrypt.Diagnostic{Code: "stack_encrypt::foreign_keyset", Message: "..."} +// return fmt.Errorf("%w: %w", encrypt.ErrForeignKeyset, d) +type Diagnostic struct { + // Code is the error's code, "crate::name": "stack_encrypt::foreign_keyset", + // "stack_kms::keyset_not_found", "stack_profile::not_found". Stable, so + // a caller may branch on it; the sentinel Unwrap returns is the coarser + // kind. + Code string + // Message is the Rust error's one-line message. Error returns it after + // the sentinel's text. + Message string + // Help says what to do about it, where the error knows. + Help string + // URL points at documentation for the error, where it has one. + URL string + // Severity is "error", "warning" or "advice". + Severity string + // Fields are the error's structured fields, by name: a foreign + // keyset's "expected" and "found", a refused plan's "field" and + // "reason", a ZeroKMS failure's "request_kind". Each value is a string, + // bool, uint64, int64, float64, nil, []any or map[string]any. + Fields map[string]any + // Causes are the errors behind this one, outermost first. + Causes []Cause + + kind error +} + +// Cause is one error in a Diagnostic's cause chain. A cause from one of the +// stack crates has a Code; one from another library has none, and its +// Message is a description the guest vouches for, never that library's own +// text. +type Cause struct { + Code string + Message string +} + +// Error returns the sentinel's text, then the Rust error's message: +// "cipherstash: auth transport failed: Server error: 403", so a log line +// still says which package and which kind of failure it was. +func (d *Diagnostic) Error() string { + if d.kind == nil { + return d.Message + } + return d.kind.Error() + ": " + d.Message +} + +// Unwrap returns the sentinel the guest's status maps to, so errors.Is +// matches the same kinds it matched before the detail existed. +func (d *Diagnostic) Unwrap() error { return d.kind } + +// The code a foreign-keyset refusal carries, the one error whose keysets +// have accessors. +const codeForeignKeyset = "stack_encrypt::foreign_keyset" + +// ExpectedKeyset is the keyset a foreign-keyset refusal expected: the one +// the cipher is bound to. ok is false for any other error. The id is an +// encrypt.KeysetID's bytes: it compares with one as is. +func (d *Diagnostic) ExpectedKeyset() (id [16]byte, ok bool) { + return d.foreignKeyset("expected") +} + +// FoundKeyset is the keyset a foreign-keyset refusal found: the one the +// ciphertext was sealed under. ok is false for any other error. +func (d *Diagnostic) FoundKeyset() (id [16]byte, ok bool) { + return d.foreignKeyset("found") +} + +func (d *Diagnostic) foreignKeyset(key string) ([16]byte, bool) { + if d.Code != codeForeignKeyset { + return [16]byte{}, false + } + text, _ := d.Fields[key].(string) + return parseUUID(text) +} + +// Field is the field of a plan, record or value the error is about, or "" +// when it names none. +func (d *Diagnostic) Field() string { + field, _ := d.Fields["field"].(string) + return field +} + +// Reason is what was wrong with a plan, context, value or stored record, as +// the snake_case name stack-encrypt's dynamic::Reason gives it +// ("field_missing", "unknown_key", "field_type", ...), or "" for an error +// that gives none. New reasons may appear: keep a fallback. +func (d *Diagnostic) Reason() string { + reason, _ := d.Fields["reason"].(string) + return reason +} + +// parseUUID parses a canonical hyphenated UUID. +func parseUUID(text string) ([16]byte, bool) { + var id [16]byte + if len(text) != 36 || text[8] != '-' || text[13] != '-' || text[18] != '-' || text[23] != '-' { + return id, false + } + hexed := text[:8] + text[9:13] + text[14:18] + text[19:23] + text[24:] + if _, err := hex.Decode(id[:], []byte(hexed)); err != nil { + return [16]byte{}, false + } + return id, true +} + +// diagnose returns the full error behind kind, the sentinel a non-zero +// status decoded to: a *Diagnostic wrapping kind, fetched through the +// guest's se_last_error and wiped, host copy and guest buffer both, once +// decoded. It returns kind itself when there is no detail to give: the +// guest has no se_last_error (one built before it), recorded nothing, or +// handed over bytes that do not decode into an error with a message. +// +// A trap in se_last_error leaves the guest in an unknown state, so the +// error then also wraps ErrTrap, for the caller to close the instance as +// it would after any trap; it still matches kind. +func (e Exports) diagnose(ctx context.Context, m api.Module, kind error) error { + if e.LastError == nil { + return kind + } + // Under a context that cannot be cancelled, as the frees are: the + // guest has returned, and a deadline that expires now must not cost + // the caller the detail of a failure already in hand. + res, err := e.LastError.Call(context.WithoutCancel(ctx)) + if err != nil { + return fmt.Errorf("%w; %w: fetching the error's detail: %w", kind, ErrTrap, err) + } + if len(res) == 0 || res[0]>>32 == 0 { + return kind + } + out := Buf{Ptr: uint32(res[0] >> 32), Len: uint32(res[0])} //nolint:gosec // splits the packed u64 into its two u32 halves + defer e.Free(ctx, out) + view, ok := m.Memory().Read(out.Ptr, out.Len) + if !ok { + return kind + } + raw := make([]byte, len(view)) + copy(raw, view) + defer Wipe(raw) + d, ok := decodeDiagnostic(raw) + if !ok { + return kind + } + d.kind = kind + return d +} + +// LastErrorExport is the module's se_last_error when it has the ABI's type, +// () -> i64, and nil otherwise, as for a guest built before it: the +// failure is then reported by its status alone. +func LastErrorExport(m api.Module) api.Function { + fn := m.ExportedFunction("se_last_error") + if fn == nil { + return nil + } + def := fn.Definition() + if len(def.ParamTypes()) != 0 || len(def.ResultTypes()) != 1 || def.ResultTypes()[0] != api.ValueTypeI64 { + return nil + } + return fn +} + +// decodeDiagnostic reads the object se_last_error encodes: code, message, +// help, url, severity, fields and causes. An error with no message is not +// one: ok is false, and the caller falls back to the sentinel. +func decodeDiagnostic(raw []byte) (*Diagnostic, bool) { + value, err := vcffi.Unmarshal(raw) + if err != nil { + return nil, false + } + object, ok := value.(vcvalue.Object) + if !ok { + return nil, false + } + d := &Diagnostic{Severity: "error", Fields: map[string]any{}} + text := map[string]*string{ + "code": &d.Code, + "message": &d.Message, + "help": &d.Help, + "url": &d.URL, + "severity": &d.Severity, + } + for _, entry := range object { + if slot, isText := text[entry.Key]; isText { + if s, isString := entry.Value.(string); isString { + *slot = s + } + continue + } + switch entry.Key { + case "fields": + if fields, isObject := entry.Value.(vcvalue.Object); isObject { + d.Fields = objectMap(fields) + } + case "causes": + d.Causes = causes(entry.Value) + } + } + if d.Message == "" { + return nil, false + } + return d, true +} + +// causes reads a list of {code?, message} objects, skipping anything else. +func causes(value any) []Cause { + items, _ := value.([]any) + var out []Cause + for _, item := range items { + entry, ok := item.(vcvalue.Object) + if !ok { + continue + } + fields := objectMap(entry) + code, _ := fields["code"].(string) + message, _ := fields["message"].(string) + out = append(out, Cause{Code: code, Message: message}) + } + return out +} + +// objectMap turns a decoded object into a map, and every object or list +// inside it likewise, so Fields holds only plain Go values. +func objectMap(object vcvalue.Object) map[string]any { + out := make(map[string]any, len(object)) + for _, entry := range object { + out[entry.Key] = plain(entry.Value) + } + return out +} + +func plain(value any) any { + switch v := value.(type) { + case vcvalue.Object: + return objectMap(v) + case []any: + out := make([]any, len(v)) + for i, item := range v { + out[i] = plain(item) + } + return out + default: + return v + } +} diff --git a/languages/golang/internal/guest/diagnostic_test.go b/languages/golang/internal/guest/diagnostic_test.go new file mode 100644 index 000000000..7b5b0e0fd --- /dev/null +++ b/languages/golang/internal/guest/diagnostic_test.go @@ -0,0 +1,402 @@ +package guest_test + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/experimental" +) + +// The fetch, decode and wipe of a guest's detail, against a hand-assembled +// guest whose se_last_error hands over fixed bytes, so each way the detail +// can be missing or broken is reachable. What the real guests record is +// tested where they are embedded (encrypt's and auth's error tests). + +// detailAt is where the stub guest keeps the bytes se_last_error hands over. +const detailAt = 1024 + +// lastErrorMode is what the stub guest's se_last_error does. +type lastErrorMode int + +const ( + withLastError lastErrorMode = iota + noLastError + trappingLastError + // outOfRangeLastError hands over a buffer that starts at the end of the + // stub's one page of memory. + outOfRangeLastError + // mistypedLastError exports se_last_error with se_dealloc's type, + // (i32, i32) -> (), which is not the ABI's. + mistypedLastError +) + +// stubGuest assembles a guest with the exports guest.Call drives, and a +// failing export: +// +// (module +// (memory (export "memory") 1) +// (func (export "se_alloc") (param i32) (result i32) i32.const 4096) +// (func (export "se_dealloc") (param i32 i32) +// local.get 0 i32.const 0 local.get 1 memory.fill) +// (func (export "fail") (param i32 i32) (result i64) i64.const ) +// (func (export "se_last_error") (result i64) i64.const ) +// (data (i32.const 1024) "")) +// +// se_dealloc zeroes, as the real guests' does, so a test can see the detail +// was released. With no detail, se_last_error returns zero (nothing +// recorded); under noLastError it is not exported at all, as in a guest +// built before it, and under trappingLastError its body is unreachable. +func stubGuest(status uint32, detail []byte, mode lastErrorMode) []byte { + packed := int64(0) + if len(detail) > 0 { + packed = int64(detailAt)<<32 | int64(len(detail)) + } + if mode == outOfRangeLastError { + packed = int64(1<<16)<<32 | 16 // starts at the end of the one-page memory + } + types := vec( + []byte{0x60, 0x01, 0x7f, 0x01, 0x7f}, // (i32) -> i32 + []byte{0x60, 0x02, 0x7f, 0x7f, 0x00}, // (i32, i32) -> () + []byte{0x60, 0x00, 0x01, 0x7e}, // () -> i64 + []byte{0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7e}, // (i32, i32) -> i64 + ) + funcs := vec([]byte{0}, []byte{1}, []byte{3}, []byte{2}) + memory := vec([]byte{0x00, 0x01}) // min 1 page, no max + exports := [][]byte{ + export("memory", 0x02, 0), + export("se_alloc", 0x00, 0), + export("se_dealloc", 0x00, 1), + export("fail", 0x00, 2), + } + switch mode { + case noLastError: + case mistypedLastError: + exports = append(exports, export("se_last_error", 0x00, 1)) + default: + exports = append(exports, export("se_last_error", 0x00, 3)) + } + lastError := body(append([]byte{0x42}, sleb(packed)...)...) + if mode == trappingLastError { + lastError = body(0x00) // unreachable + } + code := vec( + body(append([]byte{0x41}, sleb(4096)...)...), + body(0x20, 0x00, 0x41, 0x00, 0x20, 0x01, 0xfc, 0x0b, 0x00), + body(append([]byte{0x42}, sleb(int64(status))...)...), + lastError, + ) + segment := append([]byte{0x00, 0x41}, sleb(detailAt)...) + segment = append(segment, 0x0b) + segment = append(segment, uleb(uint64(len(detail)))...) + segment = append(segment, detail...) + module := []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00} + for _, s := range []struct { + id byte + body []byte + }{ + {1, types}, {3, funcs}, {5, memory}, {7, vec(exports...)}, {10, code}, {11, vec(segment)}, + } { + module = append(module, s.id) + module = append(module, uleb(uint64(len(s.body)))...) + module = append(module, s.body...) + } + return module +} + +func uleb(v uint64) []byte { + var out []byte + for { + b := byte(v & 0x7f) + v >>= 7 + if v == 0 { + return append(out, b) + } + out = append(out, b|0x80) + } +} + +func sleb(v int64) []byte { + var out []byte + for { + b := byte(v & 0x7f) + v >>= 7 + if (v == 0 && b&0x40 == 0) || (v == -1 && b&0x40 != 0) { + return append(out, b) + } + out = append(out, b|0x80) + } +} + +func vec(items ...[]byte) []byte { + out := uleb(uint64(len(items))) + for _, item := range items { + out = append(out, item...) + } + return out +} + +func export(name string, kind byte, index byte) []byte { + out := append(uleb(uint64(len(name))), name...) + return append(out, kind, index) +} + +// body is a function body with no locals. +func body(code ...byte) []byte { + b := append([]byte{0x00}, code...) + b = append(b, 0x0b) + return append(uleb(uint64(len(b))), b...) +} + +// callStub instantiates a stub guest, calls its failing export through +// guest.Call, and returns the detail's bytes as guest memory holds them +// afterwards, and the error. +func callStub(t *testing.T, status uint32, detail []byte, mode lastErrorMode) ([]byte, error) { + t.Helper() + ctx := context.Background() + rt := wazero.NewRuntime(ctx) + defer func() { _ = rt.Close(ctx) }() + alloc := guest.NewAllocator(guest.BestEffort) + m, err := rt.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, alloc), stubGuest(status, detail, mode), wazero.NewModuleConfig()) + if err != nil { + t.Fatalf("instantiating the stub guest: %v", err) + } + exports := guest.Exports{ + Alloc: m.ExportedFunction("se_alloc"), + Dealloc: m.ExportedFunction("se_dealloc"), + LastError: guest.LastErrorExport(m), + } + if (mode != noLastError && mode != mistypedLastError) != (exports.LastError != nil) { + t.Fatalf("se_last_error exported = %v under mode %d", exports.LastError != nil, mode) + } + _, callErr := guest.Call(ctx, alloc, m, exports, m.ExportedFunction("fail"), guest.BufArg([]byte("input"))) + left, ok := m.Memory().Read(detailAt, uint32(len(detail))) //nolint:gosec // a test detail is small + if !ok { + t.Fatal("reading the stub's memory") + } + return bytes.Clone(left), callErr +} + +func encode(t *testing.T, v any) []byte { + t.Helper() + raw, err := vcffi.Marshal(v) + if err != nil { + t.Fatal(err) + } + return raw +} + +// A failure with detail is a *Diagnostic: the sentinel still matches, the +// message is the Rust one, every field decodes, and the detail is wiped +// from guest memory once read. +func TestAFailureCarriesItsDiagnostic(t *testing.T) { + expected := "00000000-0000-0000-0000-000000000001" + found := "00000000-0000-0000-0000-0000000000ff" + detail := encode(t, map[string]any{ + "code": "stack_encrypt::foreign_keyset", + "message": "ciphertext was sealed under another keyset", + "help": "Open it through the client.", + "url": "https://example.com/errors/foreign_keyset", + "severity": "warning", + "fields": map[string]any{ + "expected": expected, + "found": found, + "field": "email", + "reason": "field_missing", + "count": uint64(3), + "nested": map[string]any{"list": []any{"a", uint64(1)}}, + }, + "causes": []any{ + map[string]any{"code": "stack_kms::keyset_not_found", "message": "inner"}, + map[string]any{"message": "an error from another library"}, + "not a cause", + }, + }) + left, err := callStub(t, guest.StatusForeignKeyset, detail, withLastError) + if !errors.Is(err, guest.ErrForeignKeyset) { + t.Fatalf("err = %v, want it to match ErrForeignKeyset", err) + } + var d *guest.Diagnostic + if !errors.As(err, &d) { + t.Fatalf("err = %#v, want a *Diagnostic", err) + } + if want := guest.ErrForeignKeyset.Error() + ": ciphertext was sealed under another keyset"; err.Error() != want { + t.Errorf("Error() = %q, want %q: the sentinel's text, then the Rust message", err.Error(), want) + } + if d.Code != "stack_encrypt::foreign_keyset" || d.Help != "Open it through the client." || + d.URL != "https://example.com/errors/foreign_keyset" || d.Severity != "warning" { + t.Errorf("decoded %+v", d) + } + var wantExpected, wantFound [16]byte + wantExpected[15], wantFound[15] = 0x01, 0xff + if got, ok := d.ExpectedKeyset(); !ok || got != wantExpected { + t.Errorf("ExpectedKeyset() = %x, %v; want %x", got, ok, wantExpected) + } + if got, ok := d.FoundKeyset(); !ok || got != wantFound { + t.Errorf("FoundKeyset() = %x, %v; want %x", got, ok, wantFound) + } + if d.Field() != "email" || d.Reason() != "field_missing" { + t.Errorf("Field() = %q, Reason() = %q", d.Field(), d.Reason()) + } + if d.Fields["count"] != uint64(3) { + t.Errorf("Fields[count] = %#v", d.Fields["count"]) + } + nested, _ := d.Fields["nested"].(map[string]any) + if list, _ := nested["list"].([]any); len(list) != 2 || list[0] != "a" || list[1] != uint64(1) { + t.Errorf("Fields[nested] = %#v, want plain Go values all the way down", d.Fields["nested"]) + } + want := []guest.Cause{ + {Code: "stack_kms::keyset_not_found", Message: "inner"}, + {Message: "an error from another library"}, + } + if len(d.Causes) != len(want) || d.Causes[0] != want[0] || d.Causes[1] != want[1] { + t.Errorf("Causes = %+v, want %+v", d.Causes, want) + } + if !bytes.Equal(left, make([]byte, len(detail))) { + t.Error("the detail was not wiped from guest memory") + } +} + +// The keyset accessors answer only for a foreign-keyset refusal, and only +// with an id that parses. +func TestKeysetAccessorsAnswerOnlyForAForeignKeyset(t *testing.T) { + other := &guest.Diagnostic{Code: "stack_encrypt::keyset_mismatch", Fields: map[string]any{ + "expected": "00000000-0000-0000-0000-000000000001", + }} + if _, ok := other.ExpectedKeyset(); ok { + t.Error("ExpectedKeyset answered for another code") + } + for _, bad := range []any{"not a uuid", "00000000x0000-0000-0000-000000000001", "0000000g-0000-0000-0000-000000000001", uint64(1), nil} { + d := &guest.Diagnostic{Code: "stack_encrypt::foreign_keyset", Fields: map[string]any{"found": bad}} + if _, ok := d.FoundKeyset(); ok { + t.Errorf("FoundKeyset parsed %#v", bad) + } + } + none := &guest.Diagnostic{Fields: map[string]any{}} + if none.Field() != "" || none.Reason() != "" { + t.Error("Field or Reason answered for an error that gives none") + } +} + +// With no detail to give, the failure is the bare sentinel, exactly as +// before the detail existed: a missing explanation never hides the failure. +func TestWithoutDetailTheFailureIsTheBareSentinel(t *testing.T) { + good := encode(t, map[string]any{"code": "stack_guest_abi::status", "message": "failed"}) + cases := []struct { + name string + detail []byte + mode lastErrorMode + }{ + {"a guest built before se_last_error", good, noLastError}, + {"nothing recorded", nil, withLastError}, + {"bytes that are not the codec", []byte{0xff, 0x01}, withLastError}, + {"a value that is not an object", encode(t, "a string"), withLastError}, + {"an object with no message", encode(t, map[string]any{"code": "x::y"}), withLastError}, + {"a message that is not a string", encode(t, map[string]any{"message": uint64(1)}), withLastError}, + {"a buffer past the end of guest memory", good, outOfRangeLastError}, + {"an se_last_error without the ABI's type", good, mistypedLastError}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + left, err := callStub(t, guest.StatusEncoding, c.detail, c.mode) + if err != guest.ErrEncoding { + t.Fatalf("err = %#v, want the bare ErrEncoding", err) + } + if c.mode == withLastError && !bytes.Equal(left, make([]byte, len(c.detail))) { + t.Error("a detail that did not decode was not wiped") + } + }) + } +} + +// An unknown status keeps its number, with or without detail behind it: +// a newer guest's own message for a status it added does not name it. +func TestAnUnknownStatusIsStillInternal(t *testing.T) { + detail := encode(t, map[string]any{"code": "stack_encrypt::new_kind", "message": "a new kind of failure"}) + for _, c := range []struct { + name string + detail []byte + }{{"with detail", detail}, {"without detail", nil}} { + t.Run(c.name, func(t *testing.T) { + _, err := callStub(t, 99, c.detail, withLastError) + if !errors.Is(err, guest.ErrInternal) { + t.Fatalf("err = %#v, want it to match ErrInternal", err) + } + if !strings.Contains(err.Error(), "99") { + t.Errorf("err = %q, want the status number in it", err) + } + var d *guest.Diagnostic + if found := errors.As(err, &d); found != (c.detail != nil) { + t.Fatalf("errors.As found a Diagnostic = %v, want %v", found, c.detail != nil) + } + if d != nil && (d.Code != "stack_encrypt::new_kind" || !strings.Contains(err.Error(), d.Message)) { + t.Errorf("err = %q, Code = %q", err, d.Code) + } + }) + } +} + +// A detail with only some of its keys decodes with the defaults: Severity +// "error", Fields empty and not nil, and a key of the wrong type ignored. +func TestAPartialDetailDecodesWithDefaults(t *testing.T) { + detail := encode(t, map[string]any{ + "message": "failed", + "code": uint64(7), + "fields": "not an object", + }) + _, err := callStub(t, guest.StatusEncoding, detail, withLastError) + var d *guest.Diagnostic + if !errors.As(err, &d) { + t.Fatalf("err = %#v, want a *Diagnostic", err) + } + if d.Severity != "error" || d.Code != "" || d.Fields == nil || len(d.Fields) != 0 || d.Causes != nil { + t.Errorf("decoded %+v", d) + } +} + +// A guest that aborts handing over its detail is in an unknown state: the +// error says it trapped, for the caller to close the instance, and still +// matches the failure it was reporting. +func TestATrapFetchingTheDetailIsATrap(t *testing.T) { + detail := encode(t, map[string]any{"message": "never read"}) + _, err := callStub(t, guest.StatusEncoding, detail, trappingLastError) + if !errors.Is(err, guest.ErrEncoding) || !errors.Is(err, guest.ErrTrap) { + t.Fatalf("err = %v, want it to match ErrEncoding and ErrTrap", err) + } +} + +// The detail is fetched under a context that cannot be cancelled: a +// deadline that passes once the export has returned does not cost the +// caller the detail of a failure already in hand. The real guests close +// their module when the context is done, so the stub runtime does too; a +// fetch under the caller's context would trap instead. +func TestADoneContextStillFetchesTheDetail(t *testing.T) { + ctx := context.Background() + rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithCloseOnContextDone(true)) + defer func() { _ = rt.Close(ctx) }() + detail := encode(t, map[string]any{"code": "stack_encrypt::foreign_keyset", "message": "sealed under another keyset"}) + m, err := rt.Instantiate(ctx, stubGuest(guest.StatusForeignKeyset, detail, withLastError)) + if err != nil { + t.Fatalf("instantiating the stub guest: %v", err) + } + exports := guest.Exports{ + Alloc: m.ExportedFunction("se_alloc"), + Dealloc: m.ExportedFunction("se_dealloc"), + LastError: guest.LastErrorExport(m), + } + done, cancel := context.WithCancel(ctx) + cancel() + err = exports.Diagnose(done, m, guest.ErrForeignKeyset) + if errors.Is(err, guest.ErrTrap) { + t.Fatalf("err = %v, want the detail, not a trap", err) + } + var d *guest.Diagnostic + if !errors.As(err, &d) || !errors.Is(err, guest.ErrForeignKeyset) || d.Code != "stack_encrypt::foreign_keyset" { + t.Fatalf("err = %#v, want a foreign-keyset Diagnostic", err) + } +} diff --git a/languages/golang/internal/guest/doc.go b/languages/golang/internal/guest/doc.go index 6e3af747f..c44e33da5 100644 --- a/languages/golang/internal/guest/doc.go +++ b/languages/golang/internal/guest/doc.go @@ -1,12 +1,12 @@ // Package guest is what the Go packages over the WASI guests share and // neither should own: the locked, non-dumpable memory a guest instance runs -// in; the status table every guest reports through and the errors it -// decodes to; and the opaque client key one package reads and the other -// consumes. +// in; the status table every guest reports through, the errors it decodes +// to and the Diagnostic behind each; and the opaque client key one package +// reads and the other consumes. // -// It sits under internal so that encrypt and auth expose what -// they need of it — the error sentinels, the ClientKey type — as their own -// identifiers (aliases, not copies: an error from either package is the +// It sits under internal so that encrypt and auth expose what they need of +// it — the error sentinels, the Diagnostic and ClientKey types — as their +// own identifiers (aliases, not copies: an error from either package is the // same value, and a key read by one is the type the other takes) without // either package importing the other. A binary that wants only the profile // must not carry the crypto guest, and this is the seam that makes that diff --git a/languages/golang/internal/guest/errors.go b/languages/golang/internal/guest/errors.go index 43b5a698a..347ec4609 100644 --- a/languages/golang/internal/guest/errors.go +++ b/languages/golang/internal/guest/errors.go @@ -3,9 +3,10 @@ package guest import "errors" // Failure kinds a guest surfaces across the boundary. A guest reports a -// status code and nothing else (see StatusError), so these are the whole -// vocabulary: they separate a tampered ciphertext from a bad token from a -// malformed input, and reveal nothing about plaintext or key material. The +// status code (see StatusError), and these are what the codes decode to: +// they separate a tampered ciphertext from a bad token from a malformed +// input, and reveal nothing about plaintext or key material. The detail +// behind a status is a Diagnostic, which unwraps to one of these. The // public packages expose them under their own names; the values are these. var ( // ErrAuthentication is an AEAD open failure: a tampered ciphertext, a diff --git a/languages/golang/internal/guest/export_test.go b/languages/golang/internal/guest/export_test.go new file mode 100644 index 000000000..e7f741479 --- /dev/null +++ b/languages/golang/internal/guest/export_test.go @@ -0,0 +1,14 @@ +package guest + +import ( + "context" + + "github.com/tetratelabs/wazero/api" +) + +// Diagnose is diagnose, for the external tests: it fetches a failure's +// detail under a context of the test's choosing, with no export failing +// first. +func (e Exports) Diagnose(ctx context.Context, m api.Module, kind error) error { + return e.diagnose(ctx, m, kind) +} diff --git a/languages/golang/internal/guest/status.go b/languages/golang/internal/guest/status.go index 4e3edfcd9..130834b05 100644 --- a/languages/golang/internal/guest/status.go +++ b/languages/golang/internal/guest/status.go @@ -101,10 +101,21 @@ func StatusError(status uint32) error { case StatusContextMismatch: return ErrContextMismatch default: - return fmt.Errorf("%w (unrecognized guest status %d)", ErrInternal, status) + return unrecognizedStatus(status) } } +// unrecognizedStatus is a status this host does not know: an internal +// failure whose text keeps the number, the sign of a guest newer than its +// host. +type unrecognizedStatus uint32 + +func (s unrecognizedStatus) Error() string { + return fmt.Sprintf("%v (unrecognized guest status %d)", ErrInternal, uint32(s)) +} + +func (unrecognizedStatus) Unwrap() error { return ErrInternal } + // PackedResult decodes a guest export's packed u64: a non-zero high half is // an output pointer with the length in the low half; a zero high half // carries a status code in the low half, returned as its sentinel.