From c832493b4f5a0a7629789d8afe7d5b7affcfcdb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:42:05 +0000 Subject: [PATCH 1/6] fix(golang): wipe guest buffers on failed placement and close stashgen's engine place and placeAuth left the data in a registered guest buffer when an out-slot write failed after se_alloc. They now share guest.Place, which zeroes the bytes and clears the pointer slot on any failure. stashgen's run now closes the engine it was given. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7qSiWtt22aUYDwvgVL --- languages/golang/auth/transport.go | 24 +----- languages/golang/cmd/stashgen/main.go | 9 +- languages/golang/cmd/stashgen/main_test.go | 26 ++++++ languages/golang/encrypt/transport.go | 35 +------- languages/golang/internal/guest/call.go | 44 ++++++++++ languages/golang/internal/guest/place_test.go | 83 +++++++++++++++++++ 6 files changed, 167 insertions(+), 54 deletions(-) create mode 100644 languages/golang/internal/guest/place_test.go diff --git a/languages/golang/auth/transport.go b/languages/golang/auth/transport.go index 599f40dabe..1334580bc0 100644 --- a/languages/golang/auth/transport.go +++ b/languages/golang/auth/transport.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "math" "net/http" "sort" "strings" @@ -161,7 +160,7 @@ func (t *authTransport) send(ctx context.Context, m api.Module, func (t *authTransport) placeResponse(ctx context.Context, m api.Module, hp, hl, bp, bl uint32, status int32, headers, body []byte, ) int32 { - if !placeAuth(ctx, m, hp, hl, headers) || !placeAuth(ctx, m, bp, bl, body) { + if !guest.Place(ctx, m, hp, hl, headers) || !guest.Place(ctx, m, bp, bl, body) { return -1 } return status @@ -180,31 +179,12 @@ func (t *authTransport) oidcTokenGet(ctx context.Context, m api.Module, provider } bytes := []byte(token) defer guest.Wipe(bytes) - if !placeAuth(ctx, m, ptrOut, lenOut, bytes) { + if !guest.Place(ctx, m, ptrOut, lenOut, bytes) { return 1 } return 0 } -// The only guest re-entry allowed during a host import is its allocator. -func placeAuth(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data []byte) bool { - alloc := m.ExportedFunction("se_alloc") - if alloc == nil { - return false - } - if uint64(len(data)) > math.MaxUint32 { - return false - } - res, err := alloc.Call(ctx, uint64(len(data))) - if err != nil || len(res) == 0 || res[0] == 0 { - return false - } - ptr := api.DecodeU32(res[0]) - mem := m.Memory() - return (len(data) == 0 || mem.Write(ptr, data)) && - mem.WriteUint32Le(ptrOut, ptr) && mem.WriteUint32Le(lenOut, uint32(len(data))) //nolint:gosec // bounded above -} - func parseAuthHeaders(buf []byte) http.Header { h := make(http.Header) for _, line := range strings.Split(string(buf), "\n") { diff --git a/languages/golang/cmd/stashgen/main.go b/languages/golang/cmd/stashgen/main.go index 7d1c300406..1f75db3579 100644 --- a/languages/golang/cmd/stashgen/main.go +++ b/languages/golang/cmd/stashgen/main.go @@ -52,7 +52,8 @@ func (m *modelFlags) Set(s string) error { // run is main without the process: dir is the package directory ("" for the // working directory, which is where go generate runs), and newEngine gives -// the engine that checks the declaration. +// the engine that checks the declaration. run owns that engine and closes it +// (if it is an io.Closer) before it returns. func run(args []string, dir string, stdout, stderr io.Writer, newEngine func(context.Context) (stashgen.Engine, error)) int { fs := flag.NewFlagSet("stashgen", flag.ContinueOnError) fs.SetOutput(stderr) @@ -92,6 +93,12 @@ func run(args []string, dir string, stdout, stderr io.Writer, newEngine func(con fmt.Fprintln(stderr, err) return 1 } + // The embedded engine holds a wazero runtime and a reserved address-space + // mapping; release them on every path out, since tests and embedders + // call run repeatedly. + if closer, ok := engine.(io.Closer); ok { + defer func() { _ = closer.Close() }() + } file, err := stashgen.FromTags(ctx, engine, req) if err != nil { fmt.Fprintln(stderr, err) diff --git a/languages/golang/cmd/stashgen/main_test.go b/languages/golang/cmd/stashgen/main_test.go index b832fcb4f4..ede5753dbb 100644 --- a/languages/golang/cmd/stashgen/main_test.go +++ b/languages/golang/cmd/stashgen/main_test.go @@ -233,3 +233,29 @@ func TestModelFlagsRepeat(t *testing.T) { t.Fatalf("String = %q", got) } } + +// closingEngine is a fake engine that records Close, as the embedded one +// holds a runtime that must be released. +type closingEngine struct { + enginetest.Static + closed int +} + +func (e *closingEngine) Close() error { e.closed++; return nil } + +// run closes the engine it was given on every path out of it: a refused +// declaration, a missing type, and a written file alike. +func TestRunClosesTheEngine(t *testing.T) { + dir := writeModule(t, map[string]string{"model.go": userSource}) + for name, args := range map[string][]string{ + "success": {"-type", "User"}, + "refused": {"-type", "Nobody"}, + } { + engine := &closingEngine{} + var stdout, stderr bytes.Buffer + run(args, dir, &stdout, &stderr, func(context.Context) (stashgen.Engine, error) { return engine, nil }) + if engine.closed != 1 { + t.Errorf("%s: engine closed %d times, want 1\n%s", name, engine.closed, stderr.String()) + } + } +} diff --git a/languages/golang/encrypt/transport.go b/languages/golang/encrypt/transport.go index 85e349555e..600f3b757e 100644 --- a/languages/golang/encrypt/transport.go +++ b/languages/golang/encrypt/transport.go @@ -5,13 +5,13 @@ import ( "errors" "fmt" "io" - "math" "net/http" "sort" "strings" "sync" "sync/atomic" + "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" ) @@ -89,8 +89,8 @@ func (t *transport) send(ctx context.Context, m api.Module, // The response carries wrapped key material; once it is in guest memory // the host copy is wiped. defer wipe(respBody) - if !place(ctx, m, respHeadersPtrOut, respHeadersLenOut, respHeaders) || - !place(ctx, m, respBodyPtrOut, respBodyLenOut, respBody) { + if !guest.Place(ctx, m, respHeadersPtrOut, respHeadersLenOut, respHeaders) || + !guest.Place(ctx, m, respBodyPtrOut, respBodyLenOut, respBody) { // The guest reclaims whatever was placed and refuses an unplaced // slot as an unregistered buffer; nothing more this side can do. return transportFailed @@ -225,7 +225,7 @@ func (t *transport) tokenGet(ctx context.Context, m api.Module, tokenPtrOut, tok // the source's own string is the source's. tok := []byte(token) defer wipe(tok) - if !place(ctx, m, tokenPtrOut, tokenLenOut, tok) { + if !guest.Place(ctx, m, tokenPtrOut, tokenLenOut, tok) { // The source did its part; the guest could not take the token (no // allocator, a refused allocation, an out-of-range slot). Say so, // or the failure reads as the source's. @@ -235,33 +235,6 @@ func (t *transport) tokenGet(ctx context.Context, m api.Module, tokenPtrOut, tok return 0 } -// place allocates a guest buffer through the module's own se_alloc, writes -// data into it, and stores its (ptr, len) into the out-slots. The guest -// reclaims the buffer through its registry. Re-entering the guest through -// se_alloc during a host import is the one re-entry the ABI permits. -func place(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data []byte) bool { - alloc := m.ExportedFunction("se_alloc") - if alloc == nil { - return false - } - if uint64(len(data)) > math.MaxUint32 { - return false - } - res, err := alloc.Call(ctx, uint64(len(data))) - if err != nil { - return false - } - ptr := api.DecodeU32(res[0]) - if ptr == 0 { - return false - } - mem := m.Memory() - if len(data) > 0 && !mem.Write(ptr, data) { - return false - } - return mem.WriteUint32Le(ptrOut, ptr) && mem.WriteUint32Le(lenOut, uint32(len(data))) //nolint:gosec // bounded above -} - // parseHeaders decodes the guest's `name: value` line format. Malformed // lines are skipped, as the guest skips them in the other direction. func parseHeaders(buf []byte) http.Header { diff --git a/languages/golang/internal/guest/call.go b/languages/golang/internal/guest/call.go index 0c0c48d4a8..ecc56ee80a 100644 --- a/languages/golang/internal/guest/call.go +++ b/languages/golang/internal/guest/call.go @@ -82,6 +82,50 @@ func (e Exports) Free(ctx context.Context, b Buf) { } } +// Place hands data to the guest from inside a host import: it allocates a +// guest buffer through the module's own se_alloc, writes data into it and +// stores the buffer's (ptr, len) into the guest's out-slots, which is how +// the guest comes to own and reclaim it. se_alloc is the one export the ABI +// lets a host import re-enter. +// +// It reports whether the guest took the data. When it did not, the guest +// never learns of the buffer it allocated, so Place zeroes the bytes it +// wrote in place and clears the pointer slot: the data (a bearer token, a +// ZeroKMS response) must not outlive the failure. It cannot release the +// buffer, because se_dealloc is not callable from an import; the guest's +// registry frees the zeroed bytes at shutdown. +func Place(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data []byte) bool { + alloc := m.ExportedFunction("se_alloc") + if alloc == nil { + return false + } + if uint64(len(data)) > math.MaxUint32 { + return false + } + res, err := alloc.Call(ctx, uint64(len(data))) + if err != nil || len(res) == 0 { + return false + } + ptr := api.DecodeU32(res[0]) + if ptr == 0 { + return false + } + length := uint32(len(data)) //nolint:gosec // bounded above + mem := m.Memory() + if len(data) > 0 && !mem.Write(ptr, data) { + return false + } + if mem.WriteUint32Le(ptrOut, ptr) && mem.WriteUint32Le(lenOut, length) { + return true + } + // A failed write can still have landed the first slot. + _ = mem.WriteUint32Le(ptrOut, 0) + if length > 0 { + mem.Write(ptr, make([]byte, length)) + } + return false +} + // 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 diff --git a/languages/golang/internal/guest/place_test.go b/languages/golang/internal/guest/place_test.go new file mode 100644 index 0000000000..269248ca74 --- /dev/null +++ b/languages/golang/internal/guest/place_test.go @@ -0,0 +1,83 @@ +package guest_test + +import ( + "bytes" + "context" + "testing" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/tetratelabs/wazero" +) + +// allocAt is where the stub guest's se_alloc places every buffer. +const allocAt = 4096 + +// Place stores the buffer in the guest's out-slots and leaves it there for +// the guest to reclaim. +func TestPlaceHandsTheGuestTheBuffer(t *testing.T) { + ctx := context.Background() + rt := wazero.NewRuntime(ctx) + defer func() { _ = rt.Close(ctx) }() + m, err := rt.InstantiateWithConfig(ctx, stubGuest(0, nil, withLastError), wazero.NewModuleConfig()) + if err != nil { + t.Fatal(err) + } + if !guest.Place(ctx, m, 64, 68, []byte("secret")) { + t.Fatal("Place failed on valid slots") + } + got, _ := m.Memory().Read(allocAt, 6) + if string(got) != "secret" { + t.Fatalf("buffer = %q", got) + } + ptr, _ := m.Memory().ReadUint32Le(64) + n, _ := m.Memory().ReadUint32Le(68) + if ptr != allocAt || n != 6 { + t.Fatalf("slots = (%d, %d)", ptr, n) + } +} + +// When an out-slot cannot be written the guest never learns of the buffer, +// so Place must zeroize and release it itself: the data written before the +// slot failed must not stay in guest memory. +func TestPlaceWipesTheBufferWhenASlotWriteFails(t *testing.T) { + ctx := context.Background() + const outOfRange = 1 << 16 // the end of the stub's one page + for name, slots := range map[string][2]uint32{ + "ptr slot": {outOfRange, 68}, + "len slot": {64, outOfRange}, + } { + rt := wazero.NewRuntime(ctx) + m, err := rt.InstantiateWithConfig(ctx, stubGuest(0, nil, withLastError), wazero.NewModuleConfig()) + if err != nil { + t.Fatal(err) + } + if guest.Place(ctx, m, slots[0], slots[1], []byte("secret")) { + t.Errorf("%s: Place succeeded with an out-of-range slot", name) + } + got, _ := m.Memory().Read(allocAt, 6) + if !bytes.Equal(got, make([]byte, 6)) { + t.Errorf("%s: buffer left in guest memory: %q", name, got) + } + if ptr, _ := m.Memory().ReadUint32Le(64); ptr != 0 { + t.Errorf("%s: the pointer slot still holds %d", name, ptr) + } + _ = rt.Close(ctx) + } +} + +// An empty value is placed as a zero-length buffer, not refused. +func TestPlaceTakesAnEmptyValue(t *testing.T) { + ctx := context.Background() + rt := wazero.NewRuntime(ctx) + defer func() { _ = rt.Close(ctx) }() + m, err := rt.InstantiateWithConfig(ctx, stubGuest(0, nil, withLastError), wazero.NewModuleConfig()) + if err != nil { + t.Fatal(err) + } + if !guest.Place(ctx, m, 64, 68, nil) { + t.Fatal("Place refused an empty value") + } + if n, _ := m.Memory().ReadUint32Le(68); n != 0 { + t.Fatalf("length slot = %d", n) + } +} From b17e03f56fb8a4cea45c021007b5eeee69fc33dc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 00:59:42 +0000 Subject: [PATCH 2/6] refactor(golang): share one se_alloc sequence and tighten the new tests Place and AllocWrite now build on one alloc helper, so Place's len(res) guard also covers AllocWrite. The stashgen close test asserts exit codes and covers an engine refusal; a test comment no longer claims Place releases the buffer. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7qSiWtt22aUYDwvgVL --- languages/golang/cmd/stashgen/main_test.go | 40 ++++++++++++++----- languages/golang/internal/guest/call.go | 40 +++++++++++-------- languages/golang/internal/guest/place_test.go | 4 +- 3 files changed, 56 insertions(+), 28 deletions(-) diff --git a/languages/golang/cmd/stashgen/main_test.go b/languages/golang/cmd/stashgen/main_test.go index ede5753dbb..64cabd7675 100644 --- a/languages/golang/cmd/stashgen/main_test.go +++ b/languages/golang/cmd/stashgen/main_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "errors" "os" "path/filepath" "strings" @@ -235,27 +236,46 @@ func TestModelFlagsRepeat(t *testing.T) { } // closingEngine is a fake engine that records Close, as the embedded one -// holds a runtime that must be released. +// holds a runtime that must be released. With refuse set, it refuses every +// declaration, as the embedded one does for a type it cannot produce. type closingEngine struct { enginetest.Static + refuse bool closed int } func (e *closingEngine) Close() error { e.closed++; return nil } -// run closes the engine it was given on every path out of it: a refused -// declaration, a missing type, and a written file alike. +func (e *closingEngine) Check(ctx context.Context, d stashgen.Declaration) error { + if e.refuse { + return errors.New("engine refused the declaration") + } + return e.Static.Check(ctx, d) +} + +// run closes the engine it was given on every path out of it: a written +// file, a type the package lacks, and a declaration the engine refuses. func TestRunClosesTheEngine(t *testing.T) { dir := writeModule(t, map[string]string{"model.go": userSource}) - for name, args := range map[string][]string{ - "success": {"-type", "User"}, - "refused": {"-type", "Nobody"}, - } { - engine := &closingEngine{} + cases := []struct { + name string + args []string + refuse bool + code int + }{ + {"success", []string{"-type", "User"}, false, 0}, + {"missing", []string{"-type", "Nobody"}, false, 1}, + {"refused", []string{"-type", "User"}, true, 1}, + } + for _, c := range cases { + engine := &closingEngine{refuse: c.refuse} var stdout, stderr bytes.Buffer - run(args, dir, &stdout, &stderr, func(context.Context) (stashgen.Engine, error) { return engine, nil }) + code := run(c.args, dir, &stdout, &stderr, func(context.Context) (stashgen.Engine, error) { return engine, nil }) + if code != c.code { + t.Errorf("%s: exit %d, want %d\n%s", c.name, code, c.code, stderr.String()) + } if engine.closed != 1 { - t.Errorf("%s: engine closed %d times, want 1\n%s", name, engine.closed, stderr.String()) + t.Errorf("%s: engine closed %d times, want 1\n%s", c.name, engine.closed, stderr.String()) } } } diff --git a/languages/golang/internal/guest/call.go b/languages/golang/internal/guest/call.go index ecc56ee80a..c384e3fd13 100644 --- a/languages/golang/internal/guest/call.go +++ b/languages/golang/internal/guest/call.go @@ -50,21 +50,35 @@ type Exports struct { LastError api.Function } -// AllocWrite stages data into a fresh guest buffer. -func (e Exports) AllocWrite(ctx context.Context, m api.Module, data []byte) (Buf, error) { +// alloc asks the guest's se_alloc for n bytes. It is the one allocation +// sequence behind AllocWrite and Place, so the two cannot drift on the +// bound, the result's shape or the null-pointer check. +func alloc(ctx context.Context, fn api.Function, n int) (Buf, error) { // se_alloc takes an i32: a longer length would reach the guest // truncated to its low 32 bits. - if uint64(len(data)) > math.MaxUint32 { + if uint64(n) > math.MaxUint32 { return Buf{}, errors.New("cipherstash: buffer exceeds the guest's 4 GiB address space") } - res, err := e.Alloc.Call(ctx, uint64(len(data))) + res, err := fn.Call(ctx, uint64(n)) if err != nil { return Buf{}, fmt.Errorf("%w: guest alloc: %w", ErrTrap, err) } - b := Buf{Ptr: api.DecodeU32(res[0]), Len: uint32(len(data))} //nolint:gosec // bounded above + if len(res) == 0 { + return Buf{}, errors.New("cipherstash: guest allocation returned no pointer") + } + b := Buf{Ptr: api.DecodeU32(res[0]), Len: uint32(n)} //nolint:gosec // bounded above if b.Ptr == 0 { return Buf{}, errors.New("cipherstash: guest allocation failed") } + return b, nil +} + +// AllocWrite stages data into a fresh guest buffer. +func (e Exports) AllocWrite(ctx context.Context, m api.Module, data []byte) (Buf, error) { + b, err := alloc(ctx, e.Alloc, len(data)) + if err != nil { + return Buf{}, err + } if len(data) > 0 && !m.Memory().Write(b.Ptr, data) { e.Free(ctx, b) return Buf{}, errors.New("cipherstash: guest memory write out of range") @@ -95,21 +109,15 @@ func (e Exports) Free(ctx context.Context, b Buf) { // buffer, because se_dealloc is not callable from an import; the guest's // registry frees the zeroed bytes at shutdown. func Place(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data []byte) bool { - alloc := m.ExportedFunction("se_alloc") - if alloc == nil { - return false - } - if uint64(len(data)) > math.MaxUint32 { + fn := m.ExportedFunction("se_alloc") + if fn == nil { return false } - res, err := alloc.Call(ctx, uint64(len(data))) - if err != nil || len(res) == 0 { - return false - } - ptr := api.DecodeU32(res[0]) - if ptr == 0 { + b, err := alloc(ctx, fn, len(data)) + if err != nil { return false } + ptr := b.Ptr length := uint32(len(data)) //nolint:gosec // bounded above mem := m.Memory() if len(data) > 0 && !mem.Write(ptr, data) { diff --git a/languages/golang/internal/guest/place_test.go b/languages/golang/internal/guest/place_test.go index 269248ca74..17abca5795 100644 --- a/languages/golang/internal/guest/place_test.go +++ b/languages/golang/internal/guest/place_test.go @@ -37,8 +37,8 @@ func TestPlaceHandsTheGuestTheBuffer(t *testing.T) { } // When an out-slot cannot be written the guest never learns of the buffer, -// so Place must zeroize and release it itself: the data written before the -// slot failed must not stay in guest memory. +// so Place must zeroize it in place and clear the pointer slot: the data +// written before the slot failed must not stay in guest memory. func TestPlaceWipesTheBufferWhenASlotWriteFails(t *testing.T) { ctx := context.Background() const outOfRange = 1 << 16 // the end of the stub's one page From 7abbd4d0ef3a51094486e03e27dae31e681012b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 01:01:17 +0000 Subject: [PATCH 3/6] fix(golang): take the allocation size as uint64 to satisfy gosec G115 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7qSiWtt22aUYDwvgVL --- languages/golang/internal/guest/call.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/languages/golang/internal/guest/call.go b/languages/golang/internal/guest/call.go index c384e3fd13..306ec1d8b2 100644 --- a/languages/golang/internal/guest/call.go +++ b/languages/golang/internal/guest/call.go @@ -53,13 +53,13 @@ type Exports struct { // alloc asks the guest's se_alloc for n bytes. It is the one allocation // sequence behind AllocWrite and Place, so the two cannot drift on the // bound, the result's shape or the null-pointer check. -func alloc(ctx context.Context, fn api.Function, n int) (Buf, error) { +func alloc(ctx context.Context, fn api.Function, n uint64) (Buf, error) { // se_alloc takes an i32: a longer length would reach the guest // truncated to its low 32 bits. - if uint64(n) > math.MaxUint32 { + if n > math.MaxUint32 { return Buf{}, errors.New("cipherstash: buffer exceeds the guest's 4 GiB address space") } - res, err := fn.Call(ctx, uint64(n)) + res, err := fn.Call(ctx, n) if err != nil { return Buf{}, fmt.Errorf("%w: guest alloc: %w", ErrTrap, err) } @@ -75,7 +75,7 @@ func alloc(ctx context.Context, fn api.Function, n int) (Buf, error) { // AllocWrite stages data into a fresh guest buffer. func (e Exports) AllocWrite(ctx context.Context, m api.Module, data []byte) (Buf, error) { - b, err := alloc(ctx, e.Alloc, len(data)) + b, err := alloc(ctx, e.Alloc, uint64(len(data))) if err != nil { return Buf{}, err } @@ -113,7 +113,7 @@ func Place(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data []byte if fn == nil { return false } - b, err := alloc(ctx, fn, len(data)) + b, err := alloc(ctx, fn, uint64(len(data))) if err != nil { return false } From 59379b3135dec21261d6ade227ce8d004fad190f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 01:24:37 +0000 Subject: [PATCH 4/6] fix(golang): validate Place's out-slots before allocating An out-of-range slot used to be found after se_alloc, leaving a guest buffer the guest could not learn of and costing a second allocation to wipe it. Both slots are now checked against guest memory first, so a refused placement allocates nothing. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7qSiWtt22aUYDwvgVL --- languages/golang/internal/guest/call.go | 33 +++++------- languages/golang/internal/guest/place_test.go | 52 +++++++++++-------- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/languages/golang/internal/guest/call.go b/languages/golang/internal/guest/call.go index 306ec1d8b2..1bee1173d1 100644 --- a/languages/golang/internal/guest/call.go +++ b/languages/golang/internal/guest/call.go @@ -102,36 +102,31 @@ func (e Exports) Free(ctx context.Context, b Buf) { // the guest comes to own and reclaim it. se_alloc is the one export the ABI // lets a host import re-enter. // -// It reports whether the guest took the data. When it did not, the guest -// never learns of the buffer it allocated, so Place zeroes the bytes it -// wrote in place and clears the pointer slot: the data (a bearer token, a -// ZeroKMS response) must not outlive the failure. It cannot release the -// buffer, because se_dealloc is not callable from an import; the guest's -// registry frees the zeroed bytes at shutdown. +// It reports whether the guest took the data. Both out-slots are checked +// against guest memory before anything is allocated: a buffer the guest is +// never told about can be neither read nor released, and it would hold the +// data (a bearer token, a ZeroKMS response) until shutdown. Guest memory +// only grows, so a slot that was in range stays writable after se_alloc. func Place(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data []byte) bool { fn := m.ExportedFunction("se_alloc") if fn == nil { return false } + mem := m.Memory() + // Four bytes each; uint64 so a slot near 4 GiB cannot wrap. + size := uint64(mem.Size()) + if uint64(ptrOut)+4 > size || uint64(lenOut)+4 > size { + return false + } b, err := alloc(ctx, fn, uint64(len(data))) if err != nil { return false } - ptr := b.Ptr - length := uint32(len(data)) //nolint:gosec // bounded above - mem := m.Memory() - if len(data) > 0 && !mem.Write(ptr, data) { + // A write that fails lands nothing: wazero's Write is all-or-nothing. + if len(data) > 0 && !mem.Write(b.Ptr, data) { return false } - if mem.WriteUint32Le(ptrOut, ptr) && mem.WriteUint32Le(lenOut, length) { - return true - } - // A failed write can still have landed the first slot. - _ = mem.WriteUint32Le(ptrOut, 0) - if length > 0 { - mem.Write(ptr, make([]byte, length)) - } - return false + return mem.WriteUint32Le(ptrOut, b.Ptr) && mem.WriteUint32Le(lenOut, b.Len) } // Call stages every buffer argument, calls fn with the arguments in diff --git a/languages/golang/internal/guest/place_test.go b/languages/golang/internal/guest/place_test.go index 17abca5795..8c07a073bd 100644 --- a/languages/golang/internal/guest/place_test.go +++ b/languages/golang/internal/guest/place_test.go @@ -36,32 +36,40 @@ func TestPlaceHandsTheGuestTheBuffer(t *testing.T) { } } -// When an out-slot cannot be written the guest never learns of the buffer, -// so Place must zeroize it in place and clear the pointer slot: the data -// written before the slot failed must not stay in guest memory. -func TestPlaceWipesTheBufferWhenASlotWriteFails(t *testing.T) { +// An out-slot that is out of range is refused before anything is allocated: +// no buffer the guest cannot learn of is left holding the data, and no +// guest memory is spent on it. The stub's se_alloc always returns allocAt, +// so a sentinel there that survives shows the buffer was never allocated or +// written. +func TestPlaceRefusesAnOutOfRangeSlotBeforeAllocating(t *testing.T) { ctx := context.Background() const outOfRange = 1 << 16 // the end of the stub's one page for name, slots := range map[string][2]uint32{ - "ptr slot": {outOfRange, 68}, - "len slot": {64, outOfRange}, + "ptr slot": {outOfRange, 68}, + "len slot": {64, outOfRange}, + "straddle page": {outOfRange - 2, 68}, + "near 4 GiB": {0xFFFFFFFE, 68}, } { - rt := wazero.NewRuntime(ctx) - m, err := rt.InstantiateWithConfig(ctx, stubGuest(0, nil, withLastError), wazero.NewModuleConfig()) - if err != nil { - t.Fatal(err) - } - if guest.Place(ctx, m, slots[0], slots[1], []byte("secret")) { - t.Errorf("%s: Place succeeded with an out-of-range slot", name) - } - got, _ := m.Memory().Read(allocAt, 6) - if !bytes.Equal(got, make([]byte, 6)) { - t.Errorf("%s: buffer left in guest memory: %q", name, got) - } - if ptr, _ := m.Memory().ReadUint32Le(64); ptr != 0 { - t.Errorf("%s: the pointer slot still holds %d", name, ptr) - } - _ = rt.Close(ctx) + t.Run(name, func(t *testing.T) { + rt := wazero.NewRuntime(ctx) + defer func() { _ = rt.Close(ctx) }() + m, err := rt.InstantiateWithConfig(ctx, stubGuest(0, nil, withLastError), wazero.NewModuleConfig()) + if err != nil { + t.Fatal(err) + } + sentinel := []byte("XXXXXX") + m.Memory().Write(allocAt, sentinel) + if guest.Place(ctx, m, slots[0], slots[1], []byte("secret")) { + t.Fatal("Place succeeded with an out-of-range slot") + } + got, _ := m.Memory().Read(allocAt, 6) + if !bytes.Equal(got, sentinel) { + t.Fatalf("guest memory at the allocation = %q, want it untouched", got) + } + if ptr, _ := m.Memory().ReadUint32Le(64); ptr != 0 { + t.Fatalf("the pointer slot holds %d", ptr) + } + }) } } From 7c040d922eb612cd36a6d87ba1c83e165324d999 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 02:22:22 +0000 Subject: [PATCH 5/6] docs(golang): correct the comment on an unplaced transport slot Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7qSiWtt22aUYDwvgVL --- languages/golang/encrypt/transport.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/languages/golang/encrypt/transport.go b/languages/golang/encrypt/transport.go index 600f3b757e..df10a1e8eb 100644 --- a/languages/golang/encrypt/transport.go +++ b/languages/golang/encrypt/transport.go @@ -91,8 +91,9 @@ func (t *transport) send(ctx context.Context, m api.Module, defer wipe(respBody) if !guest.Place(ctx, m, respHeadersPtrOut, respHeadersLenOut, respHeaders) || !guest.Place(ctx, m, respBodyPtrOut, respBodyLenOut, respBody) { - // The guest reclaims whatever was placed and refuses an unplaced - // slot as an unregistered buffer; nothing more this side can do. + // The guest reclaims whatever was placed. An unplaced slot reads as + // (null, 0), which the guest takes as an empty buffer, so the + // failure rides on the status; nothing more this side can do. return transportFailed } return status From 6c36046e1f5efe79f65a9d914cde96dc8f8c07bd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 03:00:03 +0000 Subject: [PATCH 6/6] fix(golang): probe Place's out-slots by reading them and count se_alloc calls in the test Memory.Size reports zero for a full 4 GiB memory, which would have refused every placement; each slot is now probed with ReadUint32Le instead. The out-of-range test counts se_alloc calls on the host side and requires zero, so a refusal that allocated first no longer passes. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7qSiWtt22aUYDwvgVL --- languages/golang/internal/guest/call.go | 10 ++++-- languages/golang/internal/guest/place_test.go | 35 +++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/languages/golang/internal/guest/call.go b/languages/golang/internal/guest/call.go index 1bee1173d1..b58f145822 100644 --- a/languages/golang/internal/guest/call.go +++ b/languages/golang/internal/guest/call.go @@ -113,9 +113,13 @@ func Place(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data []byte return false } mem := m.Memory() - // Four bytes each; uint64 so a slot near 4 GiB cannot wrap. - size := uint64(mem.Size()) - if uint64(ptrOut)+4 > size || uint64(lenOut)+4 > size { + // Probe each slot by reading it rather than comparing against + // mem.Size(): Size reports zero for a full 4 GiB memory (65536 pages), + // which would refuse every placement. + if _, ok := mem.ReadUint32Le(ptrOut); !ok { + return false + } + if _, ok := mem.ReadUint32Le(lenOut); !ok { return false } b, err := alloc(ctx, fn, uint64(len(data))) diff --git a/languages/golang/internal/guest/place_test.go b/languages/golang/internal/guest/place_test.go index 8c07a073bd..17a994c0fe 100644 --- a/languages/golang/internal/guest/place_test.go +++ b/languages/golang/internal/guest/place_test.go @@ -3,12 +3,33 @@ package guest_test import ( "bytes" "context" + "sync/atomic" "testing" "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/experimental" ) +// allocCounter counts the calls the stub guest's se_alloc receives, from the +// host side, so a test can tell "refused before allocating" from "allocated +// and left the buffer untouched". +type allocCounter struct{ calls atomic.Int32 } + +func (c *allocCounter) NewFunctionListener(def api.FunctionDefinition) experimental.FunctionListener { + if names := def.ExportNames(); len(names) > 0 && names[0] == "se_alloc" { + return c + } + return nil +} + +func (c *allocCounter) Before(context.Context, api.Module, api.FunctionDefinition, []uint64, experimental.StackIterator) { + c.calls.Add(1) +} +func (*allocCounter) After(context.Context, api.Module, api.FunctionDefinition, []uint64) {} +func (*allocCounter) Abort(context.Context, api.Module, api.FunctionDefinition, error) {} + // allocAt is where the stub guest's se_alloc places every buffer. const allocAt = 4096 @@ -18,13 +39,19 @@ func TestPlaceHandsTheGuestTheBuffer(t *testing.T) { ctx := context.Background() rt := wazero.NewRuntime(ctx) defer func() { _ = rt.Close(ctx) }() - m, err := rt.InstantiateWithConfig(ctx, stubGuest(0, nil, withLastError), wazero.NewModuleConfig()) + counter := &allocCounter{} + m, err := rt.InstantiateWithConfig(experimental.WithFunctionListenerFactory(ctx, counter), stubGuest(0, nil, withLastError), wazero.NewModuleConfig()) if err != nil { t.Fatal(err) } if !guest.Place(ctx, m, 64, 68, []byte("secret")) { t.Fatal("Place failed on valid slots") } + // The counter is only evidence of "never allocated" if it sees an + // allocation when there is one. + if n := counter.calls.Load(); n != 1 { + t.Fatalf("se_alloc was called %d times, want 1", n) + } got, _ := m.Memory().Read(allocAt, 6) if string(got) != "secret" { t.Fatalf("buffer = %q", got) @@ -53,7 +80,8 @@ func TestPlaceRefusesAnOutOfRangeSlotBeforeAllocating(t *testing.T) { t.Run(name, func(t *testing.T) { rt := wazero.NewRuntime(ctx) defer func() { _ = rt.Close(ctx) }() - m, err := rt.InstantiateWithConfig(ctx, stubGuest(0, nil, withLastError), wazero.NewModuleConfig()) + counter := &allocCounter{} + m, err := rt.InstantiateWithConfig(experimental.WithFunctionListenerFactory(ctx, counter), stubGuest(0, nil, withLastError), wazero.NewModuleConfig()) if err != nil { t.Fatal(err) } @@ -62,6 +90,9 @@ func TestPlaceRefusesAnOutOfRangeSlotBeforeAllocating(t *testing.T) { if guest.Place(ctx, m, slots[0], slots[1], []byte("secret")) { t.Fatal("Place succeeded with an out-of-range slot") } + if n := counter.calls.Load(); n != 0 { + t.Fatalf("se_alloc was called %d times, want 0", n) + } got, _ := m.Memory().Read(allocAt, 6) if !bytes.Equal(got, sentinel) { t.Fatalf("guest memory at the allocation = %q, want it untouched", got)