Skip to content

Commit 5fd317d

Browse files
committed
feat(golang)!: the crypto guest steps instead of blocking on transport_send, and the client drives it outside its lock
The crypto guest no longer imports transport_send. An export that can reach ZeroKMS (se_cipher_init, se_keyset, se_term, se_encrypt_record, se_decrypt_record, se_query_term) runs its work through the step loop: done, it returns its output as before; waiting on a request, it returns STATUS_NEEDS_IO, the host takes the handle and requests with se_io, performs them and calls se_resume with the responses, or gives up with se_abort. A step and the responses are transport frames read and written with serde (the guest's io module). Each paused operation owns its inputs, plaintext included, and shares the cipher through an Rc, so se_shutdown drops every paused operation before the cipher and its wipe is the last owner's. A second se_cipher_init is refused while one is paused. The Go client drives the loop. It holds its lock for each step only, and performs the requests with the lock free, so other callers run meanwhile instead of queuing behind a round trip. Each step runs under a context that cannot be cancelled, so the runtime never closes the module under a caller who gave up; the context is checked between steps, and an operation whose caller has gone is aborted in the guest. The token is fetched before each step, outside the lock, and token_get only hands it over. Steps of different operations may interleave; a step is read from the guest before the lock is released. Tests: the import surface is token_get alone; one load-keyset request is one step and spends its handle; a call runs while another's request is in flight; a caller that gives up mid-request gets its context's error, the operation is gone and the client still works; an ended context never starts a call; the token is fetched outside the lock; an init paused and aborted leaves neither the client key nor the bearer token in guest memory (and without the abort, the bearer remains, so the test can fail). Refs #1064.
1 parent 3e7b6d5 commit 5fd317d

25 files changed

Lines changed: 1077 additions & 226 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 no longer holds the client across a ZeroKMS round trip.** The
81+
guest returns each ZeroKMS request to the client instead of performing it
82+
through a blocking host import (`transport_send` is gone; the guest's one
83+
import is `token_get`). The client performs the request with its lock
84+
free, so other callers run meanwhile, then resumes the call. A call whose
85+
context ends between those steps returns the context's error, and the
86+
guest drops the paused call, wiping what it held; the client stays open.
87+
The token is fetched once per call, before its first step and outside
88+
the lock. This is the step loop an edge runtime's host needs, where
89+
`fetch` cannot block.
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.

‎languages/golang/encrypt/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,11 @@ that stores separate byte columns (`index=`) against real ZeroKMS.
102102

103103
A `Client` is one ZeroKMS client: its client key, its default keyset, and the
104104
keysets it has loaded since. Make one per process and share it; it is safe
105-
for concurrent use.
105+
for concurrent use. The guest runs one call at a time, but never holds the
106+
client across a ZeroKMS round trip: a call that needs ZeroKMS hands its
107+
request to the client, which performs it with the client free for other
108+
callers, and then carries on. A call whose context ends while its request
109+
is in flight returns the context's error and leaves the client open.
106110

107111
```go
108112
client, err := encrypt.NewClient(ctx)

‎languages/golang/encrypt/checker.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func (k *Checker) engineCheck(ctx context.Context, plan *record.Plan) error {
5757
if err != nil {
5858
return fmt.Errorf("%w: %v", ErrEncoding, err)
5959
}
60-
_, err = k.c.call(ctx, func(inst *instance) ([]byte, error) {
60+
_, err = k.c.call(ctx, func(ctx context.Context, inst *instance) ([]byte, error) {
6161
return inst.call(ctx, inst.planCheck, buf(encoded))
6262
})
6363
return err
@@ -66,7 +66,7 @@ func (k *Checker) engineCheck(ctx context.Context, plan *record.Plan) error {
6666
// Targets lists the EQL types this build of the engine produces: none until
6767
// the EQL target dispatch lands.
6868
func (k *Checker) Targets(ctx context.Context) ([]record.Target, error) {
69-
out, err := k.c.call(ctx, func(inst *instance) ([]byte, error) {
69+
out, err := k.c.call(ctx, func(ctx context.Context, inst *instance) ([]byte, error) {
7070
return inst.call(ctx, inst.targets)
7171
})
7272
if err != nil {
@@ -93,7 +93,7 @@ func (k *Checker) Operators(ctx context.Context, plan *record.Plan, field string
9393
if err != nil {
9494
return nil, fmt.Errorf("%w: %v", ErrEncoding, err)
9595
}
96-
out, err := k.c.call(ctx, func(inst *instance) ([]byte, error) {
96+
out, err := k.c.call(ctx, func(ctx context.Context, inst *instance) ([]byte, error) {
9797
return inst.call(ctx, inst.operators, buf(encoded), buf([]byte(field)))
9898
})
9999
if err != nil {

‎languages/golang/encrypt/client.go‎

Lines changed: 101 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import (
1717

1818
// Client is one wasm instance holding one ZeroKMS client: its key, its
1919
// default keyset, and the keysets it has loaded since. It is safe for
20-
// concurrent use; calls are serialised internally, because a wasm instance
21-
// is single-threaded. Close it when done, as with any resource.
20+
// concurrent use; the guest runs one call's step at a time, because a wasm
21+
// instance is single-threaded, but never waits on the network inside one:
22+
// a ZeroKMS request is performed between steps, with the client free for
23+
// other callers (see step.go). Close it when done, as with any resource.
2224
//
2325
// Its key material lives in the guest's linear memory, which this package
2426
// supplies: reserved once so it never moves, locked in RAM and excluded
@@ -175,7 +177,7 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error)
175177
c.releaseCredentials = resolved.Close
176178
c.credentialsLockErr = resolved.MemoryLockError
177179
owned = true
178-
out, err := c.call(ctx, func(inst *instance) ([]byte, error) {
180+
out, err := c.callKMS(ctx, func(ctx context.Context, inst *instance) ([]byte, error) {
179181
return inst.call(ctx, inst.cipherInit, buf(encoded))
180182
})
181183
if err != nil {
@@ -368,7 +370,7 @@ func (c *Client) resolveKeyset(ctx context.Context, sel KeysetSelector) (KeysetI
368370
if err != nil {
369371
return KeysetID{}, err
370372
}
371-
out, err := c.call(ctx, func(inst *instance) ([]byte, error) {
373+
out, err := c.callKMS(ctx, func(ctx context.Context, inst *instance) ([]byte, error) {
372374
return inst.call(ctx, inst.keyset, buf(encoded))
373375
})
374376
if err != nil {
@@ -382,28 +384,108 @@ func (c *Client) resolveKeyset(ctx context.Context, sel KeysetSelector) (KeysetI
382384
return id, nil
383385
}
384386

385-
// call runs f on the instance under the client's lock.
387+
// call runs an operation that never reaches ZeroKMS (a plan check, the
388+
// catalog, a verify): the step loop with no token to fetch.
389+
func (c *Client) call(ctx context.Context, f stepFunc) ([]byte, error) {
390+
return c.drive(ctx, false, f)
391+
}
392+
393+
// callKMS runs an operation that can reach ZeroKMS: the step loop, with a
394+
// token fetched before each step.
395+
func (c *Client) callKMS(ctx context.Context, f stepFunc) ([]byte, error) {
396+
return c.drive(ctx, true, f)
397+
}
398+
399+
// drive runs an operation to its end, a step at a time (see step.go): its
400+
// first step is f, and each later one resumes it with the responses to the
401+
// requests the step before asked for. The client's lock is held for each
402+
// step and released while the requests run, so callers share the client
403+
// between steps rather than queue behind a round trip. Each step runs
404+
// under a context that cannot be cancelled, so a caller's deadline never
405+
// interrupts the guest; it is checked between steps instead, and an
406+
// operation whose caller has gone is aborted, which drops it in the guest
407+
// with everything it held. When kms is set, the token is fetched once,
408+
// before the operation's first step and outside the lock, and every step
409+
// of the operation hands it over, so the guest's token_get never waits and
410+
// the token source is asked once per operation, as it was when the guest
411+
// asked for it.
412+
func (c *Client) drive(ctx context.Context, kms bool, f stepFunc) ([]byte, error) {
413+
if err := ctx.Err(); err != nil {
414+
return nil, err
415+
}
416+
token, tokenErr := "", error(nil)
417+
if kms && c.transport != nil {
418+
token, tokenErr = c.transport.fetchToken(ctx)
419+
}
420+
step := f
421+
for {
422+
out, io, err := c.step(ctx, token, tokenErr, step)
423+
if io == nil {
424+
return out, err
425+
}
426+
responses, err := c.transport.performStep(ctx, io)
427+
if err == nil {
428+
err = ctx.Err()
429+
}
430+
if err != nil {
431+
wipe(responses)
432+
c.abort(io.handle)
433+
return nil, err
434+
}
435+
handle := io.handle
436+
step = func(ctx context.Context, inst *instance) ([]byte, error) {
437+
defer wipe(responses)
438+
return inst.call(ctx, inst.resume, scalar(uint64(handle)), buf(responses))
439+
}
440+
}
441+
}
442+
443+
// abort drops a paused operation in the guest, for a caller that gave up
444+
// on it between steps. Under no deadline: the operation's plaintext and
445+
// keys are wiped only when it is dropped.
446+
func (c *Client) abort(handle uint32) {
447+
c.mu.Lock()
448+
defer c.mu.Unlock()
449+
if c.closed || c.inst.module.IsClosed() {
450+
return
451+
}
452+
ctx := context.Background()
453+
_, _ = c.inst.call(ctx, c.inst.abort, scalar(uint64(handle)))
454+
}
455+
456+
// step runs one step of an operation on the instance under the client's
457+
// lock: the operation's output when it finishes, or its next step when it
458+
// pauses, taken from the guest before the lock is released (a host reads a
459+
// step before it runs any other).
386460
//
387-
// A call interrupted by its context (the runtime closes the module on a
388-
// deadline or cancellation, see newInstance) leaves the instance closed:
389-
// its key material is gone with its memory and no further call can run.
390-
// The client is then closed, so later calls are ErrState rather than a
391-
// runtime error, and Close releases the runtime without a shutdown call.
392-
// A guest that trapped is closed the same way, by this method: the guest
393-
// builds with panic-as-abort, so a trap is an abort mid-export, after
394-
// which its state is unknown and its keys are better wiped than reused.
395-
func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([]byte, error) {
461+
// A guest that trapped is closed by this method: the guest builds with
462+
// panic-as-abort, so a trap is an abort mid-export, after which its state
463+
// is unknown and its keys are better wiped than reused. A module closed
464+
// some other way (the runtime closes one whose call context ends, and a
465+
// step's never does) closes the client too: its key material is gone with
466+
// its memory and no further call can run, so later calls are ErrState
467+
// rather than a runtime error, and Close releases the runtime without a
468+
// shutdown call.
469+
func (c *Client) step(ctx context.Context, token string, tokenErr error, f stepFunc) ([]byte, *ioStep, error) {
396470
c.mu.Lock()
397471
defer c.mu.Unlock()
398472
if c.closed || c.inst.module.IsClosed() {
399473
c.closed = true
400-
return nil, ErrState
474+
return nil, nil, ErrState
401475
}
402476
growth := c.inst.mem.GrowthRefusal()
403477
if c.transport != nil {
404-
c.transport.tokenErr = nil
478+
c.transport.held, c.transport.fetchErr, c.transport.tokenErr = token, tokenErr, nil
479+
defer func() { c.transport.held, c.transport.fetchErr = "", nil }()
480+
}
481+
run := context.WithoutCancel(ctx)
482+
out, err := f(run, c.inst)
483+
var io *ioStep
484+
if errors.Is(err, guest.ErrNeedsIO) {
485+
// A step that cannot be read cannot be resumed; the guest drops
486+
// the operation at shutdown, and refuses new pauses past its cap.
487+
io, err = c.inst.takeIO(run)
405488
}
406-
out, err := f(c.inst)
407489
switch {
408490
case c.inst.module.IsClosed():
409491
c.closed = true
@@ -439,7 +521,7 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([
439521
}
440522
}
441523
if err != nil {
442-
return nil, err
524+
return nil, nil, err
443525
}
444-
return out, nil
526+
return out, io, nil
445527
}

‎languages/golang/encrypt/diagnostic_test.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func wantCode(t *testing.T, err error, code string) {
3636
func TestGuestRefusalsCarryTheirDiagnostic(t *testing.T) {
3737
ctx := context.Background()
3838
c := rawInstance(t)
39-
_, err := c.call(ctx, func(inst *instance) ([]byte, error) {
39+
_, err := c.call(ctx, func(ctx context.Context, inst *instance) ([]byte, error) {
4040
return inst.call(ctx, inst.planCheck, buf([]byte{0xff}))
4141
})
4242
if !errors.Is(err, ErrEncoding) {
@@ -48,7 +48,7 @@ func TestGuestRefusalsCarryTheirDiagnostic(t *testing.T) {
4848
if err != nil {
4949
t.Fatal(err)
5050
}
51-
_, err = c.call(ctx, func(inst *instance) ([]byte, error) {
51+
_, err = c.call(ctx, func(ctx context.Context, inst *instance) ([]byte, error) {
5252
return inst.call(ctx, inst.keyset, buf(selector))
5353
})
5454
if !errors.Is(err, ErrState) {
@@ -65,7 +65,7 @@ func TestAGuestWithoutLastErrorFailsWithTheBareSentinel(t *testing.T) {
6565
ctx := context.Background()
6666
c := rawInstance(t)
6767
c.inst.exports.LastError = nil
68-
_, err := c.call(ctx, func(inst *instance) ([]byte, error) {
68+
_, err := c.call(ctx, func(ctx context.Context, inst *instance) ([]byte, error) {
6969
return inst.call(ctx, inst.planCheck, buf([]byte{0xff}))
7070
})
7171
if err != ErrEncoding {

‎languages/golang/encrypt/export_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ func deterministicClient(ctx context.Context, wasm []byte, seed [32]byte) (*Clie
118118
_ = c.Close()
119119
return nil, err
120120
}
121-
out, err := c.call(ctx, func(inst *instance) ([]byte, error) {
121+
out, err := c.call(ctx, func(ctx context.Context, inst *instance) ([]byte, error) {
122122
return inst.call(ctx, inst.cipherInit, buf(encoded))
123123
})
124124
if err != nil {

‎languages/golang/encrypt/guest.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ type instance struct {
8888
queryTerm, verify, limit api.Function
8989
encryptRecord, decryptRecord api.Function
9090
planCheck, targets, operators api.Function
91+
// The step loop (step.go): take a paused export's step, resume it with
92+
// the responses, or abort it.
93+
io, resume, abort api.Function
9194
}
9295

9396
// guestModuleConfig is the module configuration every guest instance runs
@@ -180,6 +183,9 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy guest.Lo
180183
"se_plan_check": &inst.planCheck,
181184
"se_targets": &inst.targets,
182185
"se_operators": &inst.operators,
186+
"se_io": &inst.io,
187+
"se_resume": &inst.resume,
188+
"se_abort": &inst.abort,
183189
}
184190
for name, slot := range exports {
185191
if *slot = module.ExportedFunction(name); *slot == nil {

‎languages/golang/encrypt/guest/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ stack-auth = { path = "../../../../packages/stack-auth", default-features = fals
5151
stack-encrypt = { path = "../../../../packages/stack-encrypt", default-features = false, features = ["dynamic"] }
5252
# The guest ABI every guest under bindings/go shares: the allocator and
5353
# buffer registry (and with them the `se_alloc`/`se_dealloc` exports), the
54-
# packed-result helpers, the status table and the `transport_send` import.
54+
# packed-result helpers, the status table and the step loop.
5555
# This crate defines only the exports that are its own.
5656
stack-guest-abi = { path = "../../../../packages/stack-guest-abi" }
5757
stack-kms = { path = "../../../../packages/stack-kms", default-features = false }

0 commit comments

Comments
 (0)