Skip to content

Commit 4fa1ee2

Browse files
committed
fix(golang): a call whose context ends fails on its own, and leaves the client open
One encrypt.Client serves every request in a service, but a call whose context was cancelled or passed its deadline closed it for all of them, and so did the auth profile a client reads its tokens from. Both runtimes are built with wazero's WithCloseOnContextDone, and every guest call ran under the caller's context, so the runtime closed the module when that context ended. Worse, the wait for the client's lock ignored the context: a request that timed out in the queue behind a slow ZeroKMS round trip reached the guest with a context already done, and wazero closed the module before any guest code ran. The fix keeps the caller's cancellation and takes it away from the runtime. internal/guest gains three pieces, used by encrypt.Client and auth.ProfileStore alike: - Lock, a one-slot lock whose wait ends with the caller's context and which refuses a context that has already ended, so a request that timed out in the queue never starts. auth.Strategy's lock uses it too. - Detach, the context a guest call runs under: the caller's values but not its cancellation, so the runtime no longer closes the module when a caller gives up. - Caller, which the host functions use to reach the caller's context: a ZeroKMS request, a CTS exchange or an OIDC provider call in flight is cancelled, fails as a transport failure, and the guest returns through its ordinary error path with its keys intact. The error matches both the context's error and ErrTransport. A backstop remains for a guest or host function that ignores cancellation: Detach's context ends guest.StuckAfter (30s) after the caller's, and the runtime then closes the module, as before. That close now means a bug, never a closed browser tab. TestInterruptedCallClosesTheClient pinned the old behaviour and is replaced by tests of the new contract: a call cancelled during a ZeroKMS request, during a token fetch, and during an auth strategy's exchange leaves the client and the profile open; a call whose deadline passes in the queue leaves without reaching the guest; a stuck call still closes the client and the profile; and many callers cancelling at random share one client without it closing. Closes #1169
1 parent c612ed4 commit 4fa1ee2

23 files changed

Lines changed: 908 additions & 121 deletions

‎languages/golang/CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`.
7777

7878
### Changed
7979

80+
- **A call whose context ends fails on its own and leaves the client
81+
open.** A cancelled or timed-out call used to close the `encrypt.Client`,
82+
or the `auth` profile behind its strategy, for every later caller, and a
83+
request whose deadline passed while it waited for the client closed it
84+
before running. Now a call waiting for the client leaves the queue with
85+
the context's error; a ZeroKMS request or a token fetch in flight is
86+
cancelled, and the error matches both the context's error and
87+
`ErrTransport`; and the client stays open. A client or profile still
88+
closes itself (`ErrState`) when the guest traps, or when a call runs on
89+
for 30 seconds after its context ended.
8090
- **stashgen refuses a match index on an index-only field**
8191
(`stash:"name,index=match"` with no `encrypt`). A match query's rows are
8292
checked by decrypting them, and such a field stores no ciphertext.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/http/httptest"
10+
"strings"
11+
"sync/atomic"
12+
"testing"
13+
"time"
14+
)
15+
16+
// A profile serves every strategy, and through them every encrypt client,
17+
// in a process: a request cancelled while its token is fetched must fail
18+
// on its own and leave the profile open for the next.
19+
20+
// tokenServer answers the access-key exchange, holding each request until
21+
// the client gives up while hold is set.
22+
func tokenServer(t *testing.T, hold *atomic.Bool) *httptest.Server {
23+
t.Helper()
24+
var jwt string
25+
// A request the client never cancels is let go at the end, so a
26+
// failure reports itself rather than hanging the server's Close.
27+
done := make(chan struct{})
28+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29+
if hold.Load() {
30+
// The server notices a client that hung up only once the body
31+
// is read.
32+
_, _ = io.Copy(io.Discard, r.Body)
33+
select {
34+
case <-r.Context().Done():
35+
case <-done:
36+
t.Error("a held request was never cancelled")
37+
}
38+
return
39+
}
40+
fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, jwt, time.Now().Add(time.Hour).Unix())
41+
}))
42+
t.Cleanup(server.Close)
43+
t.Cleanup(func() { close(done) })
44+
jwt = testJWT(t, server.URL)
45+
return server
46+
}
47+
48+
func TestATokenFetchCancelledFailsOnlyItself(t *testing.T) {
49+
guestOrSkip(t)
50+
var hold atomic.Bool
51+
server := tokenServer(t, &hold)
52+
profile, err := Open(context.Background(), t.TempDir())
53+
if err != nil {
54+
t.Fatal(err)
55+
}
56+
defer profile.Close()
57+
strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL(server.URL))
58+
if err != nil {
59+
t.Fatal(err)
60+
}
61+
defer strategy.Close()
62+
63+
hold.Store(true)
64+
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
65+
defer cancel()
66+
if _, err := strategy.Token(ctx); !errors.Is(err, context.DeadlineExceeded) {
67+
t.Fatalf("cancelled Token: %v, want the deadline", err)
68+
}
69+
if profile.root.closed || profile.root.inst.module.IsClosed() {
70+
t.Fatal("the profile was closed")
71+
}
72+
hold.Store(false)
73+
if token, err := strategy.Token(context.Background()); err != nil || token == "" {
74+
t.Fatalf("the next Token: %q, %v", token, err)
75+
}
76+
}
77+
78+
func TestATokenRequestWhoseContextEndsInTheQueueNeverReachesTheGuest(t *testing.T) {
79+
guestOrSkip(t)
80+
var hold atomic.Bool
81+
server := tokenServer(t, &hold)
82+
profile, err := Open(context.Background(), t.TempDir())
83+
if err != nil {
84+
t.Fatal(err)
85+
}
86+
defer profile.Close()
87+
strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL(server.URL))
88+
if err != nil {
89+
t.Fatal(err)
90+
}
91+
defer strategy.Close()
92+
93+
for name, lock := range map[string]interface {
94+
Acquire(context.Context) error
95+
Unlock()
96+
}{"the strategy's": &strategy.mu, "the profile's": &profile.root.mu} {
97+
t.Run(name, func(t *testing.T) {
98+
if err := lock.Acquire(context.Background()); err != nil {
99+
t.Fatal(err)
100+
}
101+
released := make(chan struct{})
102+
time.AfterFunc(200*time.Millisecond, func() { lock.Unlock(); close(released) })
103+
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
104+
defer cancel()
105+
if _, err := strategy.Token(ctx); !errors.Is(err, context.DeadlineExceeded) {
106+
t.Fatalf("queued Token: %v, want the deadline", err)
107+
}
108+
<-released
109+
})
110+
}
111+
if token, err := strategy.Token(context.Background()); err != nil || token == "" {
112+
t.Fatalf("the next Token: %q, %v", token, err)
113+
}
114+
}
115+
116+
// The backstop: a call still running long after its caller gave up closes
117+
// the profile, and says so.
118+
func TestATokenFetchThatRunsOnAfterItsContextEndsClosesTheProfile(t *testing.T) {
119+
guestOrSkip(t)
120+
profile, err := Open(context.Background(), t.TempDir(), WithRoundTripper(roundTripFunc(func(*http.Request) (*http.Response, error) {
121+
// A RoundTripper that ignores the request's context.
122+
time.Sleep(300 * time.Millisecond)
123+
return nil, errors.New("too late")
124+
})))
125+
if err != nil {
126+
t.Fatal(err)
127+
}
128+
defer profile.Close()
129+
strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL("https://cts.example.com"))
130+
if err != nil {
131+
t.Fatal(err)
132+
}
133+
defer strategy.Close()
134+
profile.root.stuck = 20 * time.Millisecond
135+
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
136+
defer cancel()
137+
if _, err := strategy.Token(ctx); err == nil || !strings.Contains(err.Error(), "the profile is closed") {
138+
t.Fatalf("stuck Token: %v, want the profile closed", err)
139+
}
140+
if _, err := strategy.Token(context.Background()); !errors.Is(err, ErrState) {
141+
t.Fatalf("a Token after the close: %v, want ErrState", err)
142+
}
143+
}

‎languages/golang/auth/doc.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@
3939
// [OAuth2TokenSource] adapts an existing golang.org/x/oauth2.TokenSource
4040
// into the OIDC provider interface.
4141
//
42+
// A call whose context ends, [Strategy.Token] included, fails with the
43+
// context's error and leaves the store and its strategies open: a request
44+
// cancelled while its token is exchanged or refreshed does not stop the
45+
// encrypt clients that read tokens from the same store. The store closes
46+
// itself, and every later call is [ErrState], only when its guest traps or
47+
// a call runs on for 30 seconds after its context ended.
48+
//
4249
// # Why a second guest
4350
//
4451
// The crypto guest behind encrypt has no filesystem and no

‎languages/golang/auth/guest.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest.
111111
return nil, fmt.Errorf("%w: %s: %w", ErrNoProfile, hostDir, err)
112112
}
113113
}
114+
// A call never runs under its caller's context (see guest.Detach), so
115+
// this closes the module only for a call that ran on long after its
116+
// caller gave up: ProfileStore.callArgs.
114117
config := wazero.NewRuntimeConfig().
115118
WithCompilationCache(compilationCache()).
116119
WithCloseOnContextDone(true)
@@ -183,8 +186,8 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest.
183186

184187
// release runs the guest's shutdown — every buffer it still holds wiped —
185188
// and closes the runtime, which frees the linear memory through the
186-
// allocator's wipe, then the directory handle the mount holds. A module an
187-
// interrupted call or a trap already closed cannot run sa_shutdown; the
189+
// allocator's wipe, then the directory handle the mount holds. A module a
190+
// stuck call or a trap already closed cannot run sa_shutdown; the
188191
// runtime close still wipes and frees its memory.
189192
func (inst *instance) release() error {
190193
ctx := context.Background()

‎languages/golang/auth/store.go‎

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
"path/filepath"
1111
"runtime"
1212
"strings"
13-
"sync"
13+
"time"
1414

1515
"github.com/cipherstash/stack/languages/golang/internal/guest"
1616
"github.com/cipherstash/vitaminc/bindings/go/vcffi"
@@ -53,6 +53,13 @@ func RequireLockedMemory() Option {
5353
// stores from one root share one guest instance over the one mounted
5454
// directory; Close on any of them releases it. It is safe for concurrent
5555
// use; calls are serialised, because a wasm instance is single-threaded.
56+
//
57+
// A call whose context ends fails with the context's error, and only that
58+
// call: a request cancelled while its token is fetched or refreshed never
59+
// closes the profile, or the strategies and encrypt clients that read
60+
// tokens from it. The profile closes itself, and every later call is
61+
// [ErrState], only when the guest's state is unknown: it trapped, or it
62+
// went on running long after its caller gave up, which is a bug.
5663
type ProfileStore struct {
5764
root *root
5865
// dir is the store's directory as the guest sees it: guestRoot, or a
@@ -62,13 +69,17 @@ type ProfileStore struct {
6269

6370
// root is the guest instance the stores of one profile share.
6471
type root struct {
65-
mu sync.Mutex
72+
mu guest.Lock
6673
inst *instance
6774
hostDir string
68-
closed bool
69-
// released is the runtime's own state, apart from closed: an
70-
// interrupted call closes the module, and so the stores, while the
71-
// runtime is still allocated. Close frees it even then.
75+
// stuck is how long a call may run on after its context ends before
76+
// the guest is treated as stuck and closed. guest.StuckAfter; tests
77+
// shorten it.
78+
stuck time.Duration
79+
closed bool
80+
// released is the runtime's own state, apart from closed: a stuck
81+
// call closes the module, and so the stores, while the runtime is
82+
// still allocated. Close frees it even then.
7283
released bool
7384
cleanup runtime.Cleanup
7485
}
@@ -135,7 +146,7 @@ func open(ctx context.Context, hostDir string, opts []Option) (*ProfileStore, er
135146
if err != nil {
136147
return nil, err
137148
}
138-
r := &root{inst: inst, hostDir: hostDir}
149+
r := &root{inst: inst, hostDir: hostDir, stuck: guest.StuckAfter}
139150
// The cleanup takes the instance, not the root: a cleanup whose
140151
// argument reaches its object keeps that object alive forever.
141152
r.cleanup = runtime.AddCleanup(r, func(inst *instance) { _ = inst.release() }, inst)
@@ -204,7 +215,7 @@ func (s *ProfileStore) Close() error {
204215
type export func(*instance) api.Function
205216

206217
// call runs one export under the profile's lock, closing the profile if
207-
// the guest trapped or an interrupted call took the module down.
218+
// the guest trapped or a stuck call took the module down.
208219
func (s *ProfileStore) call(ctx context.Context, fn export, args ...string) ([]byte, error) {
209220
// Every profile export works on the mount; without one the guest would
210221
// report an I/O error that says less than this does.
@@ -221,23 +232,31 @@ func (s *ProfileStore) call(ctx context.Context, fn export, args ...string) ([]b
221232

222233
// callArgs is the shared checked call path for profile exports and auth
223234
// exports. The latter pass secret-bearing byte buffers and wipe host copies.
235+
//
236+
// The caller's context bounds the wait for the lock, as encrypt's
237+
// Client.call does, and the guest runs under guest.Detach, so a caller
238+
// that gives up cancels its token request without closing the profile.
224239
func (s *ProfileStore) callArgs(ctx context.Context, fn export, args ...guest.Arg) ([]byte, error) {
225240
r := s.root
226-
r.mu.Lock()
241+
if err := r.mu.Acquire(ctx); err != nil {
242+
return nil, err
243+
}
227244
defer r.mu.Unlock()
228245
if r.closed || r.inst.module.IsClosed() {
229246
r.closed = true
230247
return nil, ErrState
231248
}
232249
growth := r.inst.mem.GrowthRefusal()
233-
out, err := guest.Call(ctx, r.inst.mem, r.inst.module, r.inst.exports, fn(r.inst), args...)
250+
run, done := guest.Detach(ctx, r.stuck)
251+
out, err := guest.Call(run, r.inst.mem, r.inst.module, r.inst.exports, fn(r.inst), args...)
252+
done()
234253
switch {
235254
case r.inst.module.IsClosed():
236255
r.closed = true
237256
if err == nil {
238257
err = ErrState
239258
}
240-
err = fmt.Errorf("%w: interrupted call closed the profile", err)
259+
err = fmt.Errorf("%w: the guest ran on after its caller's context ended, and the profile is closed", err)
241260
case errors.Is(err, guest.ErrTrap):
242261
r.closed = true
243262
_ = r.inst.module.Close(context.Background())
@@ -253,6 +272,9 @@ func (s *ProfileStore) callArgs(ctx context.Context, fn export, args ...guest.Ar
253272
err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", guest.MemoryLockError(g.Reason), err)
254273
}
255274
if err != nil {
275+
if ctxErr := ctx.Err(); ctxErr != nil && !errors.Is(err, ctxErr) {
276+
err = fmt.Errorf("%w: %w", ctxErr, err)
277+
}
256278
return nil, err
257279
}
258280
return out, nil

‎languages/golang/auth/strategy.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"fmt"
88
"os"
99
"strconv"
10-
"sync"
1110

1211
"github.com/cipherstash/stack/languages/golang/internal/guest"
1312
"github.com/tetratelabs/wazero/api"
@@ -58,7 +57,7 @@ type Strategy struct {
5857
handle string
5958
providerID uint32
6059
device bool
61-
mu sync.Mutex
60+
mu guest.Lock
6261
closed bool
6362
}
6463

@@ -182,7 +181,11 @@ func (s *ProfileStore) hasToken(ctx context.Context) (bool, error) {
182181
// Token gets the current CTS bearer credential. Device sessions read a fresh
183182
// token without a file lock; only a refresh call takes the cross-process lock.
184183
func (s *Strategy) Token(ctx context.Context) (string, error) {
185-
s.mu.Lock()
184+
// A request queued behind another's refresh leaves when its context
185+
// ends, as at the profile's own lock.
186+
if err := s.mu.Acquire(ctx); err != nil {
187+
return "", err
188+
}
186189
defer s.mu.Unlock()
187190
if s.closed {
188191
return "", ErrState

‎languages/golang/auth/transport.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,8 @@ func (t *authTransport) send(ctx context.Context, m api.Module,
124124
return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte("request buffer out of range"))
125125
}
126126
requestBody := newAuthRequestBody(body)
127-
req, err := http.NewRequestWithContext(ctx, string(method), string(url), requestBody)
127+
// The caller's context, not the call's: see guest.Detach.
128+
req, err := http.NewRequestWithContext(guest.Caller(ctx), string(method), string(url), requestBody)
128129
if err != nil {
129130
_ = requestBody.Close()
130131
return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte(err.Error()))
@@ -174,7 +175,7 @@ func (t *authTransport) oidcTokenGet(ctx context.Context, m api.Module, provider
174175
if source == nil {
175176
return 1
176177
}
177-
token, err := source.Token(ctx)
178+
token, err := source.Token(guest.Caller(ctx))
178179
if err != nil || token == "" || strings.ContainsAny(token, "\r\n\x00") {
179180
return 1
180181
}

‎languages/golang/encrypt/README.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,21 @@ ZeroKMS round trip `NewClient` makes. It has nothing to do with an
117117
*encryption* context, which is what a field is sealed under; that is the
118118
`context=` tag.
119119

120+
Every call takes a `ctx` too, and a call whose `ctx` is cancelled or passes
121+
its deadline fails with the context's error. Only that call fails: one
122+
request giving up never closes the shared client for the others.
123+
124+
- A call waiting for the client (it runs one call at a time) leaves the
125+
queue when its `ctx` ends, and never starts.
126+
- A call waiting on ZeroKMS, or on its auth strategy for a token, has that
127+
request cancelled. Its error matches both `context.Canceled` or
128+
`context.DeadlineExceeded` and `ErrTransport`.
129+
130+
A client closes itself, and every later call returns `ErrState`, only when
131+
the guest's state is unknown: the guest trapped, or a call ran on for 30
132+
seconds after its `ctx` ended, which means a bug. A service that sees
133+
`ErrState` from a client it did not close builds a new one.
134+
120135
Every other setting is a functional option with a default:
121136

122137
```go
@@ -372,7 +387,11 @@ or for stored data. Check an error two ways.
372387
its field's context (ZeroKMS refuses the key, or the AEAD fails).
373388
- `ErrEncoding`: a stored value or a call that does not fit the declaration.
374389
- `ErrUnauthorized`, `ErrNotFound`, `ErrTransport`, `ErrKMS`: ZeroKMS.
375-
- `ErrState`: a call on a closed client. `ErrMemoryLock`: see below.
390+
- `ErrState`: a call on a closed client: you closed it, or the guest
391+
trapped or ran on long after a call's context ended (see Connect).
392+
`ErrMemoryLock`: see below.
393+
- `context.Canceled`, `context.DeadlineExceeded`: the call's `ctx` ended.
394+
The client is still open.
376395

377396
`errors.As` with a `*Diagnostic` gives you the detail behind a failure the
378397
engine reports: a stable `Code` (`stack_encrypt::foreign_keyset`,

0 commit comments

Comments
 (0)