diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6cb08638d..6b4c31cb3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -211,8 +211,8 @@ updates: - /packages/stack-auth/fuzz - /packages/stack-kms/fuzz - /packages/stack-encrypt/fuzz - - /languages/golang/stackencrypt/guest - - /languages/golang/stackauth/guest + - /languages/golang/encrypt/guest + - /languages/golang/auth/guest # Monthly, matching the other two cargo entries. schedule: interval: monthly diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index a15437f37..67531f441 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -8,8 +8,10 @@ name: Tests (Go) # wasi-check the stack crates build for wasm32-wasip1 with no JS-host or # native-HTTP dependencies, the no-http shape passes its tests # and docs, both guests pass lint and tests and are built with -# their import surfaces checked, their sha256 is recorded, and -# `go:test` runs against them. +# their import surfaces checked (the stack-encrypt guest twice: +# the real build and the deterministic-kms test build), their +# sha256 is recorded, `go:test` runs against them, and +# `go generate` leaves the tree unchanged. # go-lint golangci-lint, Linux only. # go-binding-cross # the same Go tests on macOS and Windows, against the guests @@ -122,8 +124,8 @@ jobs: with: workspaces: | . - languages/golang/stackencrypt/guest - languages/golang/stackauth/guest + languages/golang/encrypt/guest + languages/golang/auth/guest # The HTTP-free core compiles for wasm32-wasip1 with no JS-host backend # and no native HTTP/TLS stack in its graph: the invariant the wazero @@ -146,6 +148,14 @@ jobs: - name: stack-encrypt guest release build and import-surface gate run: mise run wasm:guest:build + # The deterministic-kms TEST build, under encrypt/testdata where no + # build or embed sees it: the Go tests open the record fixture Rust + # sealed through it, and run round trips with no ZeroKMS. The tests + # skip when it is absent, so a build that forgets this step would pass + # with less coverage, which is why the job builds it unconditionally. + - name: stack-encrypt guest deterministic test build + run: mise run wasm:guest:build:deterministic + - name: Credential guest lint and tests run: mise run wasm:auth-guest:test @@ -157,7 +167,7 @@ jobs: # same bytes rather than a stale or rebuilt guest. - name: Record the guests' checksums run: | - for guest in stackencrypt/wasm/stack_encrypt_guest.wasm stackauth/wasm/stack_auth_guest.wasm; do + for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do (cd languages/golang && openssl dgst -sha256 "$guest" | awk '{print $NF}' > "$guest.sha256" && echo "$guest sha256 $(cat "$guest.sha256")") done @@ -166,25 +176,45 @@ jobs: with: name: wasm-guests path: | - languages/golang/stackencrypt/wasm/stack_encrypt_guest.wasm - languages/golang/stackencrypt/wasm/stack_encrypt_guest.wasm.sha256 - languages/golang/stackauth/wasm/stack_auth_guest.wasm - languages/golang/stackauth/wasm/stack_auth_guest.wasm.sha256 + languages/golang/encrypt/wasm/stack_encrypt_guest.wasm + languages/golang/encrypt/wasm/stack_encrypt_guest.wasm.sha256 + languages/golang/encrypt/testdata/stack_encrypt_guest_deterministic.wasm + languages/golang/encrypt/testdata/stack_encrypt_guest_deterministic.wasm.sha256 + languages/golang/auth/wasm/stack_auth_guest.wasm + languages/golang/auth/wasm/stack_auth_guest.wasm.sha256 if-no-files-found: error retention-days: 1 # Format, vet and hermetic tests on amd64 and 386. The guest's memory # lock is best effort, so its test skips where RLIMIT_MEMLOCK refuses - # it; CI raises the limit and sets STACKENCRYPT_TESTS_REQUIRE_LOCK so + # it; CI raises the limit and sets STACK_ENCRYPT_TESTS_REQUIRE_LOCK so # the skip is an error here. - name: Go binding env: - STACKENCRYPT_TESTS_REQUIRE_LOCK: "1" + STACK_ENCRYPT_TESTS_REQUIRE_LOCK: "1" run: | ulimit -l "$(ulimit -H -l)" echo "RLIMIT_MEMLOCK: $(ulimit -l) KiB" mise run go:test + # What is encrypted is fixed before the program ships: every generated + # file is committed, and this fails when `go generate` would change + # one. It runs the real stashgen against the guest just built, so it + # also proves the generator and the engine agree on the module's own + # examples. `git diff` sees only tracked files, so the status check + # catches a generated file that was never committed. + - name: Generated code is committed + working-directory: languages/golang + run: | + CGO_ENABLED=0 go generate ./... + git diff --exit-code -- . + untracked="$(git status --porcelain -- .)" + if [ -n "$untracked" ]; then + echo "::error::go generate wrote files that are not committed:" + echo "$untracked" + exit 1 + fi + # Linux only: macOS and Windows would report the same findings. Needs no # guests: the packages embed a directory and compile without them. go-lint: @@ -260,7 +290,7 @@ jobs: - name: The guests are the ones Linux built and checked run: | cd languages/golang - for guest in stackencrypt/wasm/stack_encrypt_guest.wasm stackauth/wasm/stack_auth_guest.wasm; do + for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do want=$(cat "$guest.sha256") got=$(openssl dgst -sha256 "$guest" | awk '{print $NF}') if [ "$want" != "$got" ]; then @@ -326,7 +356,7 @@ jobs: - name: The guests are the ones the wasi-check job built and checked run: | cd languages/golang - for guest in stackencrypt/wasm/stack_encrypt_guest.wasm stackauth/wasm/stack_auth_guest.wasm; do + for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do want=$(cat "$guest.sha256") got=$(openssl dgst -sha256 "$guest" | awk '{print $NF}') if [ "$want" != "$got" ]; then @@ -340,7 +370,7 @@ jobs: # call `liveClient`, and not all of them are named `TestLive*`. - name: Go live tests working-directory: languages/golang - run: CGO_ENABLED=0 go test -v ./stackencrypt/... + run: CGO_ENABLED=0 go test -v ./encrypt/... - name: stack-encrypt examples run: | diff --git a/.gitignore b/.gitignore index 65da48ae3..b638c7cbb 100644 --- a/.gitignore +++ b/.gitignore @@ -108,7 +108,11 @@ mutants.out/ # The Go module's embedded WASI guests: build outputs of `mise run # wasm:guest:build` and `mise run wasm:auth-guest:build`. -languages/golang/stackencrypt/wasm/*.wasm -languages/golang/stackauth/wasm/*.wasm -languages/golang/stackencrypt/wasm/*.sha256 -languages/golang/stackauth/wasm/*.sha256 +languages/golang/encrypt/wasm/*.wasm +languages/golang/auth/wasm/*.wasm +languages/golang/encrypt/wasm/*.sha256 +languages/golang/auth/wasm/*.sha256 +# The deterministic-kms TEST build of the stack-encrypt guest, from `mise run +# wasm:guest:build:deterministic`; under testdata so no binary embeds it. +languages/golang/encrypt/testdata/*.wasm +languages/golang/encrypt/testdata/*.sha256 diff --git a/AGENTS.md b/AGENTS.md index ce21666b0..da9c497b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l links are provenance only. - `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io, one version group), `stack-kms` (published to crates.io from 0.1.0, its own version group, re-exported by `stack-encrypt` as `stack_encrypt::kms`), `stack-encrypt` and `stack-encrypt-derive` (published to crates.io from 0.1.0, one version group; `eql-bindings`' `stack-encrypt` feature depends on them from the registry), and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". - `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. -- `languages/golang`: The Go module (`stackencrypt`, `stackauth`, `internal`), a wazero host with no cgo. Its two WASI guests (`*/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; the `.wasm` files they embed are gitignored. There is no Go release process yet. +- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. There is no Go release process yet. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). diff --git a/Cargo.lock b/Cargo.lock index 94e2dcaeb..60687ec98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3281,7 +3281,6 @@ dependencies = [ "cllw-ore", "serde", "serde_json", - "sha2 0.10.9", "stack-auth", "stack-encrypt-derive", "stack-kms", diff --git a/Cargo.toml b/Cargo.toml index 4ad564ff7..3da394ab4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ exclude = [ "packages/stack-encrypt/fuzz", # WASI guests for the Go module, built through `wasm:guest:build` and # `wasm:auth-guest:build`. - "languages/golang/stackencrypt/guest", - "languages/golang/stackauth/guest", + "languages/golang/encrypt/guest", + "languages/golang/auth/guest", ] [workspace.package] diff --git a/SECURITY.md b/SECURITY.md index e63869144..821c8fad4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,7 +33,7 @@ It also carries the source of five Rust crates published to crates.io, `packages/stack-profile`) and **`stack-kms`**, **`stack-encrypt`** and **`stack-encrypt-derive`** (`packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`), and of the **Go module** at -`languages/golang` (`stackencrypt` and `stackauth`, over WASI guests built +`languages/golang` (`encrypt` and `auth`, over WASI guests built from the stack-* crates), which has no release yet. All of these are in scope for security reports on the same terms as the npm packages above. diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index b596cf753..bd45d5433 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -16,6 +16,13 @@ the builder itself, #1071), with a second round of decisions: decrypt is spelled `open`, the two starts and the three context sources, the typed verb and the picker, the derive narrowed before it emits the plan, and EQL types assembled per language with no registry. +Amended 2026-10-07 (#1094): where this document says the typed parts "have +no data form" and lists `context_field` among them ("Consolidation", and +item 3 under "Why the first draft was dropped"), it is wrong about +`context_field`. That verb is not typed, and the data grammar now spells it +as a plan-level `"context_field"` key; the Go SDK spells it as the +`context_field` tag word. The typed parts that have no data form are +`encrypt_into`, the picker and the one-value start. **Date:** 2026-10-04 **Issue:** #1046 **Builds on:** #1050 (one context per column; `Label`, `Describe`), #971 (EQL v3 diff --git a/languages/golang/.gitattributes b/languages/golang/.gitattributes new file mode 100644 index 000000000..21d155a76 --- /dev/null +++ b/languages/golang/.gitattributes @@ -0,0 +1,10 @@ +# stashgen's golden test expectations. Marked `linguist-generated` so GitHub +# collapses them in pull-request and commit diffs by default and excludes them +# from repository language statistics. This is a GitHub display hint only: +# nothing about Git, CI, or the build changes. +# +# The generated *_stash.go files are deliberately NOT marked. They are the +# committed record of which fields are encrypted, with which indexes and under +# which context, so a change to them must stay expanded for the reviewer to +# read (docs/sdk-design-principles.md, Go principle 10). +*.golden linguist-generated diff --git a/languages/golang/stackauth/README.md b/languages/golang/auth/README.md similarity index 77% rename from languages/golang/stackauth/README.md rename to languages/golang/auth/README.md index 396939df3..79c6d60eb 100644 --- a/languages/golang/stackauth/README.md +++ b/languages/golang/auth/README.md @@ -1,9 +1,9 @@ -# stackauth +# auth The Go binding of the developer profile — the directory `stash auth login` writes — read through the `stack-profile` Rust crate running inside a WASI guest under [wazero], with `CGO_ENABLED=0`. It is the credential half of -the Go SDK: it hands a [`stackencrypt`](../stackencrypt) client its client +the Go SDK: it hands an [`encrypt`](../encrypt) client its client key and its bearer token without either package re-deriving the profile's layout, and without either importing the other. @@ -16,9 +16,9 @@ cross-process refresh lock for device sessions. ## Use -Most applications never call this package directly: a `stackencrypt` +Most applications never call this package directly: an `encrypt` client built with `NewClient(ctx)` and no options resolves its credentials with -`stackencrypt.AutoCredentials`, which reads the environment first and then +`encrypt.AutoCredentials`, which reads the environment first and then the profile, through this package. Use it directly to take the profile apart yourself: @@ -26,12 +26,12 @@ apart yourself: import ( "context" - "github.com/cipherstash/stack/languages/golang/stackauth" - "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/auth" + "github.com/cipherstash/stack/languages/golang/encrypt" ) func run(ctx context.Context) error { - profile, err := stackauth.Resolve(ctx) // CS_CONFIG_PATH, else ~/.cipherstash + profile, err := auth.Resolve(ctx) // CS_CONFIG_PATH, else ~/.cipherstash if err != nil { return err } @@ -39,7 +39,7 @@ func run(ctx context.Context) error { workspace, err := profile.CurrentWorkspaceStore(ctx) if err != nil { - return err // stackauth.ErrNoCurrentWorkspace: run `stash auth login` + return err // auth.ErrNoCurrentWorkspace: run `stash auth login` } clientID, clientKey, err := workspace.SecretKey(ctx) if err != nil { @@ -50,9 +50,9 @@ func run(ctx context.Context) error { return err } defer source.Close() - client, err := stackencrypt.NewClient(ctx, + client, err := encrypt.NewClient(ctx, // The key is consumed and wiped by NewClient. - stackencrypt.WithCredentials(stackencrypt.NewCredentials(clientID, clientKey, source)), + encrypt.WithCredentials(encrypt.NewCredentials(clientID, clientKey, source)), ) if err != nil { return err @@ -63,32 +63,32 @@ func run(ctx context.Context) error { } ``` -`stackauth.ClientKey` and `stackencrypt.ClientKey` are one type, so the +`auth.ClientKey` and `encrypt.ClientKey` are one type, so the key goes straight from the profile into the credentials. The profile and the strategy are the caller's: the client asks the strategy for a token on every request but never closes it, so both stay open until the client is closed (the deferred calls above run in that order). -A stackencrypt client takes its token only from a strategy, never a raw +An encrypt client takes its token only from a strategy, never a raw string: a raw token cannot be refreshed when it expires, and would bypass the cross-process lock a device-session refresh holds with the `stash` CLI (the IdP revokes a whole refresh-token chain when one is used twice). `workspace.Token(ctx)` still reads the stored token, for inspection. With no profile directory at all (CI, a container, a server authenticating -by federation), `stackauth.OpenWithoutProfile(ctx)` runs the guest with +by federation), `auth.OpenWithoutProfile(ctx)` runs the guest with nothing mounted: the access-key and OIDC strategies work, and every profile read is `ErrNoProfile`. `profile.AccessKey(ctx, crn, key)`, `profile.OIDC(ctx, crn, provider)`, and -`profile.Auto(ctx)` also return strategies that `stackencrypt.NewCredentials` +`profile.Auto(ctx)` also return strategies that `encrypt.NewCredentials` takes. `Auto` checks `CS_CLIENT_ACCESS_KEY` and `CS_WORKSPACE_CRN` first, then the current workspace's stored device session. The OIDC provider is a one-method `Token(context.Context) (string, error)` interface, called on every token fetch for the JWT of the user the call is for; each distinct JWT is exchanged once while its CTS token lasts. Use -`stackauth.OAuth2TokenSource(source)` to adapt a -`golang.org/x/oauth2.TokenSource`. `WithAuthBaseURL(url)` overrides service +`auth.OAuth2TokenSource(source)` to adapt a +`golang.org/x/oauth2.TokenSource`. `WithBaseURL(url)` overrides service discovery for local tests or a custom CTS host; `WithCacheCapacity(n)` sets how many users' CTS tokens an OIDC strategy keeps (1024 unless set), sized to the users it serves within a CTS token's lifetime. @@ -107,7 +107,7 @@ session refresh call, on the path `ProfileStore.LockPath` names. A fresh token is read without the lock; on refresh the guest re-reads auth.json after acquisition and saves refreshed tokens before release. -The crypto guest behind `stackencrypt` is not widened by this package +The crypto guest behind `encrypt` is not widened by this package existing: it still has no filesystem and no environment. ## Build diff --git a/languages/golang/stackauth/clientkey.go b/languages/golang/auth/clientkey.go similarity index 51% rename from languages/golang/stackauth/clientkey.go rename to languages/golang/auth/clientkey.go index 30553833b..e880f7a26 100644 --- a/languages/golang/stackauth/clientkey.go +++ b/languages/golang/auth/clientkey.go @@ -1,12 +1,12 @@ -package stackauth +package auth import "github.com/cipherstash/stack/languages/golang/internal/guest" // ClientKey is the ZeroKMS client key as [ProfileStore.SecretKey] reads it // out of secretkey.json: opaque (it prints a redaction under every verb and // hands its bytes to no caller) and wiped once consumed. It is the same -// type as stackencrypt.ClientKey, by identity, so a key read here goes -// straight into stackencrypt.NewCredentials. This package does not import -// stackencrypt: a binary that only wants the profile does not carry the -// crypto guest. (stackencrypt imports this one, for AutoCredentials.) +// type as encrypt.ClientKey, by identity, so a key read here goes +// straight into encrypt.NewCredentials. This package does not import +// encrypt: a binary that only wants the profile does not carry the +// crypto guest. (encrypt imports this one, for AutoCredentials.) type ClientKey = guest.ClientKey diff --git a/languages/golang/stackauth/doc.go b/languages/golang/auth/doc.go similarity index 85% rename from languages/golang/stackauth/doc.go rename to languages/golang/auth/doc.go index 46feff9b0..b0bf4f16d 100644 --- a/languages/golang/stackauth/doc.go +++ b/languages/golang/auth/doc.go @@ -1,4 +1,4 @@ -// Package stackauth is the Go binding of the developer profile: the +// Package auth is the Go binding of the developer profile: the // directory `stash auth login` writes (~/.cipherstash, or CS_CONFIG_PATH), // read through the stack-profile crate running unmodified inside a WASI // guest under wazero (CGO_ENABLED=0), so the on-disk layout is never @@ -21,27 +21,27 @@ // one workspace ([ProfileStore.WorkspaceStore], // [ProfileStore.CurrentWorkspaceStore]), and the typed reads of the files a // workspace holds: [ProfileStore.SecretKey] hands out the ZeroKMS client key -// as the opaque [ClientKey] that stackencrypt.NewCredentials takes, +// as the opaque [ClientKey] that encrypt.NewCredentials takes, // [ProfileStore.Token] the stored access token, [ProfileStore.DeviceIdentity] // the identity the CLI created. [ProfileStore.Close] releases the guest; // stores scoped from it are closed with it. // // For authentication and refresh, use [ProfileStore.AccessKey], // [ProfileStore.OIDC], [ProfileStore.DeviceSession], or [ProfileStore.Auto]. -// Each returns a [Strategy], which is what stackencrypt.NewCredentials takes -// for the bearer token: the only way a token reaches a stackencrypt client. +// Each returns a [Strategy], which is what encrypt.NewCredentials takes +// for the bearer token: the only way a token reaches an encrypt client. // A raw token — [ProfileStore.Token]'s included — cannot be refreshed when // it expires, and would bypass the cross-process lock a device-session -// refresh holds with the CLI, so stackencrypt does not accept one. A +// refresh holds with the CLI, so encrypt does not accept one. A // strategy lives in its store's guest, and closing the store closes it. A -// stackencrypt client given one never closes the strategy or the store: +// encrypt client given one never closes the strategy or the store: // both are the caller's, and stay open until the client is closed. // [OAuth2TokenSource] adapts an existing golang.org/x/oauth2.TokenSource // into the OIDC provider interface. // // # Why a second guest // -// The crypto guest behind stackencrypt has no filesystem and no +// The crypto guest behind encrypt has no filesystem and no // environment: a bug or compromise inside it cannot read credentials off // disk. Mounting the profile into it would trade that away, and it is the // module that handles plaintext and data keys. So the profile lives in its @@ -63,9 +63,9 @@ // # Memory // // The guest's memory holds the client key and the token while a read is -// in flight. It is supplied the way stackencrypt's is — reserved once so it +// in flight. It is supplied the way encrypt's is — reserved once so it // never moves, locked in RAM and excluded from core dumps where the // platform allows, wiped before release, none of it depending on Close // running — and [ProfileStore.MemoryLocked] reports whether the lock was // granted. [RequireLockedMemory] makes a refused lock an error from Open. -package stackauth +package auth diff --git a/languages/golang/stackauth/errors.go b/languages/golang/auth/errors.go similarity index 84% rename from languages/golang/stackauth/errors.go rename to languages/golang/auth/errors.go index b5a2746f2..72eeba7a9 100644 --- a/languages/golang/stackauth/errors.go +++ b/languages/golang/auth/errors.go @@ -1,4 +1,4 @@ -package stackauth +package auth import ( "errors" @@ -9,7 +9,7 @@ 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 -// stackencrypt of the same kind is the same value. +// encrypt of the same kind is the same value. 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 @@ -46,12 +46,12 @@ var ( ErrUsageLimit = guest.ErrAuthUsageLimit // ErrNotAuthenticated means no usable auth credential is available. ErrNotAuthenticated = guest.ErrAuthNotAuthenticated - // ErrAuthTransport is a failed auth HTTP exchange or response read. - ErrAuthTransport = guest.ErrAuthTransport - // ErrAuthConfig is invalid auth configuration or token data. - ErrAuthConfig = guest.ErrAuthConfig - // ErrAuthOther is an auth failure outside the actionable categories above. - ErrAuthOther = guest.ErrAuthOther + // ErrTransport is a failed auth HTTP exchange or response read. + ErrTransport = guest.ErrAuthTransport + // ErrConfig is invalid auth configuration or token data. + ErrConfig = guest.ErrAuthConfig + // ErrOther is an auth failure outside the actionable categories above. + ErrOther = guest.ErrAuthOther // ErrMemoryLock is guest memory that could not be locked in RAM (or, on // Linux, excluded from core dumps). Open returns it under // [RequireLockedMemory]; otherwise [ProfileStore.MemoryLockError] @@ -60,5 +60,5 @@ var ( // ErrNoProfile is a profile directory that does not exist: nothing has // logged in on this machine, or CS_CONFIG_PATH names the wrong place. - ErrNoProfile = errors.New("stackauth: no profile directory; run `stash auth login`") + ErrNoProfile = errors.New("auth: no profile directory; run `stash auth login`") ) diff --git a/languages/golang/stackauth/guest.go b/languages/golang/auth/guest.go similarity index 93% rename from languages/golang/stackauth/guest.go rename to languages/golang/auth/guest.go index dc7239267..2d157f5b0 100644 --- a/languages/golang/stackauth/guest.go +++ b/languages/golang/auth/guest.go @@ -1,4 +1,4 @@ -package stackauth +package auth import ( "context" @@ -33,7 +33,7 @@ const guestRoot = "/profile" // ErrGuestNotBuilt is returned by Open when no guest module is embedded and // none was supplied with [WithGuest]. -var ErrGuestNotBuilt = errors.New("stackauth: guest module not built — run `mise run wasm:auth-guest:build`") +var ErrGuestNotBuilt = errors.New("auth: guest module not built — run `mise run wasm:auth-guest:build`") func embeddedGuest() ([]byte, error) { wasm, err := guestFS.ReadFile(guestPath) @@ -82,7 +82,7 @@ type instance struct { // because wazero's default is a fixed seed: the Rust runtime draws through // it (its hash maps are seeded from it, for one), and nothing a guest does // should be predictable across instances. The clocks are the system's for -// the same reason they are in stackencrypt: a deterministic default is the +// the same reason they are in encrypt: a deterministic default is the // wrong default for anything that reads time. Each is pinned by a test. // // A nil mount is a guest with no directory at all ([OpenWithoutProfile]): @@ -123,11 +123,11 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest. return nil, err } if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { - return fail(fmt.Errorf("stackauth: instantiating WASI: %w", err)) + return fail(fmt.Errorf("auth: instantiating WASI: %w", err)) } transport := newAuthTransport(rt) if err := transport.instantiate(ctx, runtime); err != nil { - return fail(fmt.Errorf("stackauth: instantiating host transport: %w", err)) + return fail(fmt.Errorf("auth: instantiating host transport: %w", err)) } mem := guest.NewAllocator(policy) // The guest is a reactor (cdylib): no _start. wazero runs _initialize @@ -142,7 +142,7 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest. if g := mem.GrowthRefusal(); g.Refused != 0 { return fail(fmt.Errorf("%w: %w", guest.MemoryLockError(g.Reason), err)) } - return fail(fmt.Errorf("stackauth: instantiating guest: %w", err)) + return fail(fmt.Errorf("auth: instantiating guest: %w", err)) } if policy == guest.Strict { if lerr := mem.LockError(); lerr != nil { @@ -172,7 +172,7 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest. } for name, slot := range exports { if *slot = module.ExportedFunction(name); *slot == nil { - return fail(fmt.Errorf("stackauth: guest is missing export %s", name)) + return fail(fmt.Errorf("auth: guest is missing export %s", name)) } } return inst, nil diff --git a/languages/golang/stackauth/guest/.gitignore b/languages/golang/auth/guest/.gitignore similarity index 100% rename from languages/golang/stackauth/guest/.gitignore rename to languages/golang/auth/guest/.gitignore diff --git a/languages/golang/stackauth/guest/Cargo.lock b/languages/golang/auth/guest/Cargo.lock similarity index 100% rename from languages/golang/stackauth/guest/Cargo.lock rename to languages/golang/auth/guest/Cargo.lock diff --git a/languages/golang/stackauth/guest/Cargo.toml b/languages/golang/auth/guest/Cargo.toml similarity index 96% rename from languages/golang/stackauth/guest/Cargo.toml rename to languages/golang/auth/guest/Cargo.toml index 8bb48dba1..d3928e995 100644 --- a/languages/golang/stackauth/guest/Cargo.toml +++ b/languages/golang/auth/guest/Cargo.toml @@ -1,6 +1,6 @@ # The credential guest: `stack-profile` and `stack-auth` strategies under # WASI/wazero, embedded by the Go package -# `stackauth` one directory up. ADR-0005 in packages/stack-encrypt/docs/adr. +# `auth` one directory up. ADR-0005 in packages/stack-encrypt/docs/adr. # # A second module rather than the crypto guest widened: this one is given a # directory of credentials, and that one handles plaintext and data keys. diff --git a/languages/golang/stackauth/guest/src/abi.rs b/languages/golang/auth/guest/src/abi.rs similarity index 100% rename from languages/golang/stackauth/guest/src/abi.rs rename to languages/golang/auth/guest/src/abi.rs diff --git a/languages/golang/stackauth/guest/src/auth.rs b/languages/golang/auth/guest/src/auth.rs similarity index 99% rename from languages/golang/stackauth/guest/src/auth.rs rename to languages/golang/auth/guest/src/auth.rs index beaee521f..cbdbef6a8 100644 --- a/languages/golang/stackauth/guest/src/auth.rs +++ b/languages/golang/auth/guest/src/auth.rs @@ -34,7 +34,7 @@ enum Config { provider: u32, base_url: Option, /// How many distinct JWTs keep a CTS token; the strategy's default - /// when absent. See `stackauth.WithCacheCapacity`. + /// when absent. See `auth.WithCacheCapacity`. cache_capacity: Option, }, DeviceSession { diff --git a/languages/golang/stackauth/guest/src/headers.rs b/languages/golang/auth/guest/src/headers.rs similarity index 100% rename from languages/golang/stackauth/guest/src/headers.rs rename to languages/golang/auth/guest/src/headers.rs diff --git a/languages/golang/stackauth/guest/src/host.rs b/languages/golang/auth/guest/src/host.rs similarity index 100% rename from languages/golang/stackauth/guest/src/host.rs rename to languages/golang/auth/guest/src/host.rs diff --git a/languages/golang/stackauth/guest/src/lib.rs b/languages/golang/auth/guest/src/lib.rs similarity index 97% rename from languages/golang/stackauth/guest/src/lib.rs rename to languages/golang/auth/guest/src/lib.rs index 421057ef0..fb9986d4c 100644 --- a/languages/golang/stackauth/guest/src/lib.rs +++ b/languages/golang/auth/guest/src/lib.rs @@ -33,7 +33,7 @@ //! WASI guest module exposing the developer profile — //! [`stack_profile::ProfileStore`] over one mounted directory — to non-Rust //! hosts. Built for `wasm32-wasip1` and embedded by the Go package -//! `stackauth` in the parent directory (wazero host, `CGO_ENABLED=0`). +//! `auth` in the parent directory (wazero host, `CGO_ENABLED=0`). //! ADR-0005 in `packages/stack-encrypt/docs/adr` is the decision this //! module implements; `docs/plans/stack-encrypt-go-bindings.md` sequences //! it. @@ -49,7 +49,7 @@ //! the mount does not contain: every path is built by `stack-profile` from //! a store directory and a validated filename or workspace id. //! -//! The crypto guest (`bindings/go/stackencrypt/guest`) is unchanged by +//! The crypto guest (`languages/golang/encrypt/guest`) is unchanged by //! this one existing. It has no filesystem and no environment, and it //! handles plaintext and data keys; this module can reach one directory of //! credentials. The split is what makes both of those true at once. diff --git a/languages/golang/stackauth/guest/src/ops.rs b/languages/golang/auth/guest/src/ops.rs similarity index 99% rename from languages/golang/stackauth/guest/src/ops.rs rename to languages/golang/auth/guest/src/ops.rs index 6e5ea6388..4e684cb82 100644 --- a/languages/golang/stackauth/guest/src/ops.rs +++ b/languages/golang/auth/guest/src/ops.rs @@ -39,7 +39,7 @@ use crate::status::{status_for_profile, STATUS_ENCODING, STATUS_INTERNAL}; /// and `stack-kms`'s `SecretKey` reads it: the ZeroKMS client id and the /// client key material, standard padded base64. The key crosses to the /// host in the form the file holds, which is one of the two forms -/// `stackencrypt`'s config takes — as bytes, so the host gets a slice it +/// `encrypt`'s config takes — as bytes, so the host gets a slice it /// can wipe rather than a string it cannot. What is deserialized here is /// wiped when it drops; what is moved out of it into the codec value is /// wiped by the codec's own protected types. diff --git a/languages/golang/stackauth/guest/src/status.rs b/languages/golang/auth/guest/src/status.rs similarity index 100% rename from languages/golang/stackauth/guest/src/status.rs rename to languages/golang/auth/guest/src/status.rs diff --git a/languages/golang/stackauth/lock_unix.go b/languages/golang/auth/lock_unix.go similarity index 85% rename from languages/golang/stackauth/lock_unix.go rename to languages/golang/auth/lock_unix.go index a8bc65c54..2d193c604 100644 --- a/languages/golang/stackauth/lock_unix.go +++ b/languages/golang/auth/lock_unix.go @@ -1,6 +1,6 @@ //go:build !windows -package stackauth +package auth import ( "context" @@ -16,7 +16,7 @@ import ( func withRefreshLock(ctx context.Context, path string, run func() error) error { f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // path is inside the caller's profile directory if err != nil { - return fmt.Errorf("stackauth: open refresh lock: %w", err) + return fmt.Errorf("auth: open refresh lock: %w", err) } defer f.Close() for { @@ -28,7 +28,7 @@ func withRefreshLock(ctx context.Context, path string, run func() error) error { break } if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EAGAIN) { - return fmt.Errorf("stackauth: acquire refresh lock: %w", err) + return fmt.Errorf("auth: acquire refresh lock: %w", err) } select { case <-ctx.Done(): diff --git a/languages/golang/stackauth/lock_windows.go b/languages/golang/auth/lock_windows.go similarity index 87% rename from languages/golang/stackauth/lock_windows.go rename to languages/golang/auth/lock_windows.go index c327a8336..1d901ef2a 100644 --- a/languages/golang/stackauth/lock_windows.go +++ b/languages/golang/auth/lock_windows.go @@ -1,6 +1,6 @@ //go:build windows -package stackauth +package auth import ( "context" @@ -17,7 +17,7 @@ import ( func withRefreshLock(ctx context.Context, path string, run func() error) error { f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // path is inside the caller's profile directory if err != nil { - return fmt.Errorf("stackauth: open refresh lock: %w", err) + return fmt.Errorf("auth: open refresh lock: %w", err) } defer f.Close() h := windows.Handle(f.Fd()) @@ -31,7 +31,7 @@ func withRefreshLock(ctx context.Context, path string, run func() error) error { break } if !errors.Is(err, windows.ERROR_LOCK_VIOLATION) { - return fmt.Errorf("stackauth: acquire refresh lock: %w", err) + return fmt.Errorf("auth: acquire refresh lock: %w", err) } select { case <-ctx.Done(): diff --git a/languages/golang/stackauth/mount.go b/languages/golang/auth/mount.go similarity index 99% rename from languages/golang/stackauth/mount.go rename to languages/golang/auth/mount.go index f4ef72e3d..718b87d79 100644 --- a/languages/golang/stackauth/mount.go +++ b/languages/golang/auth/mount.go @@ -1,4 +1,4 @@ -package stackauth +package auth import ( "errors" diff --git a/languages/golang/stackauth/oauth2.go b/languages/golang/auth/oauth2.go similarity index 76% rename from languages/golang/stackauth/oauth2.go rename to languages/golang/auth/oauth2.go index 37056a790..be7f83b01 100644 --- a/languages/golang/stackauth/oauth2.go +++ b/languages/golang/auth/oauth2.go @@ -1,4 +1,4 @@ -package stackauth +package auth import ( "context" @@ -12,14 +12,14 @@ import ( func OAuth2TokenSource(source oauth2.TokenSource) OIDCProvider { return OIDCProviderFunc(func(context.Context) (string, error) { if source == nil { - return "", errors.New("stackauth: nil oauth2 token source") + return "", errors.New("auth: nil oauth2 token source") } token, err := source.Token() if err != nil { return "", err } if token == nil || token.AccessToken == "" { - return "", errors.New("stackauth: oauth2 source returned no access token") + return "", errors.New("auth: oauth2 source returned no access token") } return token.AccessToken, nil }) diff --git a/languages/golang/stackauth/store.go b/languages/golang/auth/store.go similarity index 97% rename from languages/golang/stackauth/store.go rename to languages/golang/auth/store.go index cc04be9d4..b5baea7a6 100644 --- a/languages/golang/stackauth/store.go +++ b/languages/golang/auth/store.go @@ -1,4 +1,4 @@ -package stackauth +package auth import ( "context" @@ -42,7 +42,7 @@ func WithGuest(wasm []byte) Option { // memory cannot be locked in RAM or, on Linux, excluded from core dumps, // instead of continuing with memory that may be swapped or dumped and // reporting so through [ProfileStore.MemoryLocked]. It holds for the life -// of the store, as stackencrypt's WithRequireLockedMemory does for a +// of the store, as encrypt's WithRequireLockedMemory does for a // client. func RequireLockedMemory() Option { return func(o *options) { o.requireLocked = true } @@ -159,7 +159,7 @@ func (s *ProfileStore) hostPath(guestPath string) string { // MemoryLocked reports whether the guest's memory — where the client key // and the token pass through — is locked in RAM and, on Linux, excluded -// from core dumps. See stackencrypt's Client.MemoryLocked for what false +// from core dumps. See encrypt's Client.MemoryLocked for what false // means and what to do about it. func (s *ProfileStore) MemoryLocked() bool { return s.root.inst.mem.LockError() == nil } @@ -175,7 +175,7 @@ func (s *ProfileStore) MemoryLockError() error { // String prints the store's directory and memory state. Nothing secret. func (s *ProfileStore) String() string { - return fmt.Sprintf("stackauth.ProfileStore{dir: %s, memory: %s}", s.Dir(), s.root.inst.mem) + return fmt.Sprintf("auth.ProfileStore{dir: %s, memory: %s}", s.Dir(), s.root.inst.mem) } // LogValue implements slog.LogValuer: the directory, and the memory state. @@ -246,7 +246,7 @@ func (s *ProfileStore) callArgs(ctx context.Context, fn export, args ...guest.Ar // Under RequireLockedMemory a growth that cannot be locked is refused, // and the guest sees only a failed allocation — or, for an allocation // of its own, aborts, and the trap closed the profile above. Name the - // real cause either way, as stackencrypt's Client.call does. The + // real cause either way, as encrypt's Client.call does. The // refusal is this call's, not the store's: the range went back unused, // so MemoryLocked still holds. if g := r.inst.mem.GrowthRefusal(); err != nil && g.Refused != growth.Refused { @@ -355,7 +355,7 @@ func (s *ProfileStore) LockPath(ctx context.Context, filename string) (string, e // SecretKey reads secretkey.json in this store (a workspace store; the // root holds none): the ZeroKMS client id and the client key, the latter as -// the opaque [ClientKey] stackencrypt.NewCredentials takes. The transport copy +// the opaque [ClientKey] encrypt.NewCredentials takes. The transport copy // of the key is wiped once it is in the ClientKey; the key is then the // caller's to consume. func (s *ProfileStore) SecretKey(ctx context.Context) (clientID string, key *ClientKey, err error) { diff --git a/languages/golang/stackauth/store_test.go b/languages/golang/auth/store_test.go similarity index 99% rename from languages/golang/stackauth/store_test.go rename to languages/golang/auth/store_test.go index a07374065..ce5370e1d 100644 --- a/languages/golang/stackauth/store_test.go +++ b/languages/golang/auth/store_test.go @@ -1,4 +1,4 @@ -package stackauth +package auth import ( "context" diff --git a/languages/golang/stackauth/strategy.go b/languages/golang/auth/strategy.go similarity index 94% rename from languages/golang/stackauth/strategy.go rename to languages/golang/auth/strategy.go index 2ed0dd196..5fadc7bce 100644 --- a/languages/golang/stackauth/strategy.go +++ b/languages/golang/auth/strategy.go @@ -1,4 +1,4 @@ -package stackauth +package auth import ( "context" @@ -21,8 +21,8 @@ type strategyOptions struct { cacheCapacity *uint32 } -// WithAuthBaseURL overrides CTS service discovery for one strategy. -func WithAuthBaseURL(url string) StrategyOption { +// WithBaseURL overrides CTS service discovery for one strategy. +func WithBaseURL(url string) StrategyOption { return func(o *strategyOptions) { o.baseURL = url } } @@ -48,9 +48,9 @@ func strategyConfig(opts []StrategyOption) strategyOptions { } // Strategy is a Rust stack-auth strategy retained inside the credential -// guest: the source of the bearer token stackencrypt.NewCredentials takes. +// guest: the source of the bearer token encrypt.NewCredentials takes. // Close drops its cached credential; closing the parent profile closes all -// its strategies. It is the caller's to close: a stackencrypt client that +// its strategies. It is the caller's to close: an encrypt client that // was given it asks it for tokens but never closes it, so it must stay open // until the client is closed. type Strategy struct { @@ -65,7 +65,7 @@ type Strategy struct { func (s *ProfileStore) newStrategy(ctx context.Context, config any, device bool) (*Strategy, error) { data, err := json.Marshal(config) if err != nil { - return nil, fmt.Errorf("stackauth: encode strategy: %w", err) + return nil, fmt.Errorf("auth: encode strategy: %w", err) } defer guest.Wipe(data) out, err := s.callArgs(ctx, func(i *instance) api.Function { return i.authNew }, guest.BufArg(data)) @@ -82,7 +82,7 @@ func (s *ProfileStore) newStrategy(ctx context.Context, config any, device bool) // stays in the guest after construction; it is not sent on each Token call. func (s *ProfileStore) AccessKey(ctx context.Context, crn, key string, opts ...StrategyOption) (*Strategy, error) { if crn == "" || key == "" { - return nil, ErrAuthConfig + return nil, ErrConfig } o := strategyConfig(opts) return s.newStrategy(ctx, struct { @@ -100,7 +100,7 @@ func (s *ProfileStore) AccessKey(ctx context.Context, crn, key string, opts ...S // JWTs that cache holds. func (s *ProfileStore) OIDC(ctx context.Context, crn string, provider OIDCProvider, opts ...StrategyOption) (*Strategy, error) { if crn == "" || provider == nil { - return nil, ErrAuthConfig + return nil, ErrConfig } o := strategyConfig(opts) id := s.root.inst.transport.register(provider) @@ -125,7 +125,7 @@ func (s *ProfileStore) OIDC(ctx context.Context, crn string, provider OIDCProvid // CLI, then the guest re-reads and saves before the lock is released. func (s *ProfileStore) DeviceSession(ctx context.Context, opts ...StrategyOption) (*Strategy, error) { if s.dir == guestRoot { - return nil, ErrAuthConfig + return nil, ErrConfig } o := strategyConfig(opts) return s.newStrategy(ctx, struct { @@ -147,7 +147,7 @@ func (s *ProfileStore) Auto(ctx context.Context, opts ...StrategyOption) (*Strat } if key, keySet := os.LookupEnv("CS_CLIENT_ACCESS_KEY"); keySet { if !crnSet { - return nil, ErrAuthConfig + return nil, ErrConfig } return s.AccessKey(ctx, crn, key, opts...) } diff --git a/languages/golang/stackauth/strategy_test.go b/languages/golang/auth/strategy_test.go similarity index 93% rename from languages/golang/stackauth/strategy_test.go rename to languages/golang/auth/strategy_test.go index a98fca173..3715ea115 100644 --- a/languages/golang/stackauth/strategy_test.go +++ b/languages/golang/auth/strategy_test.go @@ -1,4 +1,4 @@ -package stackauth +package auth import ( "context" @@ -90,7 +90,7 @@ func TestAccessKeyStrategyCachesAndPreservesRequest(t *testing.T) { t.Fatal(err) } defer profile.Close() - strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL(server.URL)) if err != nil { t.Fatal(err) } @@ -154,7 +154,7 @@ func TestOIDCStrategyFederatesEachProviderTokenOnce(t *testing.T) { return "", errors.New("provider did not receive the Token caller's context") } return idp, nil - }), WithAuthBaseURL(server.URL)) + }), WithBaseURL(server.URL)) if err != nil { t.Fatal(err) } @@ -210,7 +210,7 @@ func TestOIDCStrategyCacheCapacityZeroExchangesEveryCall(t *testing.T) { defer profile.Close() strategy, err := profile.OIDC(context.Background(), testCRN, OIDCProviderFunc(func(context.Context) (string, error) { return "idp-a", nil - }), WithAuthBaseURL(server.URL), WithCacheCapacity(0)) + }), WithBaseURL(server.URL), WithCacheCapacity(0)) if err != nil { t.Fatal(err) } @@ -241,7 +241,7 @@ func TestUsageLimitIsPreservedAcrossGuest(t *testing.T) { t.Fatal(err) } defer profile.Close() - strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL(server.URL)) if err != nil { t.Fatal(err) } @@ -269,7 +269,7 @@ func TestDeviceRefreshReportsInvalidClient(t *testing.T) { if err != nil { t.Fatal(err) } - strategy, err := ws.DeviceSession(context.Background(), WithAuthBaseURL(server.URL)) + strategy, err := ws.DeviceSession(context.Background(), WithBaseURL(server.URL)) if err != nil { t.Fatal(err) } @@ -312,7 +312,7 @@ func TestAutoPrefersAccessKeyThenDeviceSession(t *testing.T) { } t.Setenv("CS_CLIENT_ACCESS_KEY", "CSAKtestKeyId.testKeySecret") t.Setenv("CS_WORKSPACE_CRN", testCRN) - strategy, err := profile.Auto(context.Background(), WithAuthBaseURL(server.URL)) + strategy, err := profile.Auto(context.Background(), WithBaseURL(server.URL)) if err != nil { t.Fatal(err) } @@ -327,7 +327,7 @@ func TestAutoPrefersAccessKeyThenDeviceSession(t *testing.T) { if err := os.Unsetenv("CS_CLIENT_ACCESS_KEY"); err != nil { t.Fatal(err) } - strategy, err = profile.Auto(context.Background(), WithAuthBaseURL(server.URL)) + strategy, err = profile.Auto(context.Background(), WithBaseURL(server.URL)) if err != nil { t.Fatal(err) } @@ -342,7 +342,7 @@ func TestAutoPrefersAccessKeyThenDeviceSession(t *testing.T) { if err := profile.ClearCurrentWorkspace(context.Background()); err != nil { t.Fatal(err) } - if _, err := profile.Auto(context.Background(), WithAuthBaseURL(server.URL)); !errors.Is(err, ErrNotAuthenticated) { + if _, err := profile.Auto(context.Background(), WithBaseURL(server.URL)); !errors.Is(err, ErrNotAuthenticated) { t.Fatalf("no credentials: error = %v, want %v", err, ErrNotAuthenticated) } } @@ -372,7 +372,7 @@ func TestDeviceSessionFreshTokenDoesNotTakeRefreshLock(t *testing.T) { if err != nil { t.Fatal(err) } - strategy, err := workspace.DeviceSession(context.Background(), WithAuthBaseURL("https://cts.example.com")) + strategy, err := workspace.DeviceSession(context.Background(), WithBaseURL("https://cts.example.com")) if err != nil { t.Fatal(err) } @@ -423,7 +423,7 @@ func TestDeviceSessionMissingAndInvalidProfilesKeepTheirErrors(t *testing.T) { if err != nil { t.Fatal(err) } - strategy, err := workspace.DeviceSession(context.Background(), WithAuthBaseURL("https://cts.example.com")) + strategy, err := workspace.DeviceSession(context.Background(), WithBaseURL("https://cts.example.com")) if err != nil { t.Fatal(err) } @@ -448,28 +448,28 @@ func TestAutoUsesEnvironmentPresenceAndProfileExistence(t *testing.T) { } t.Setenv("CS_WORKSPACE_CRN", testCRN) t.Setenv("CS_CLIENT_ACCESS_KEY", "") - if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrAuthConfig) { - t.Fatalf("set but empty access key: error = %v, want %v", err, ErrAuthConfig) + if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrConfig) { + 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") - if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrAuthConfig) { - t.Fatalf("malformed access key: error = %v, want %v", err, ErrAuthConfig) + if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrConfig) { + t.Fatalf("malformed access key: error = %v, want %v", err, ErrConfig) } - if _, err := profile.AccessKey(context.Background(), "invalid", "CSAKtestKeyId.testKeySecret"); !errors.Is(err, ErrAuthConfig) { - t.Fatalf("malformed CRN for access key: error = %v, want %v", err, ErrAuthConfig) + 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) } provider := OIDCProviderFunc(func(context.Context) (string, error) { return "", nil }) - if _, err := profile.OIDC(context.Background(), "invalid", provider); !errors.Is(err, ErrAuthConfig) { - t.Fatalf("malformed CRN for OIDC: error = %v, want %v", err, ErrAuthConfig) + if _, err := profile.OIDC(context.Background(), "invalid", provider); !errors.Is(err, ErrConfig) { + t.Fatalf("malformed CRN for OIDC: error = %v, want %v", err, ErrConfig) } if err := os.Unsetenv("CS_CLIENT_ACCESS_KEY"); err != nil { t.Fatal(err) } t.Setenv("CS_WORKSPACE_CRN", "invalid") - if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrAuthConfig) { - t.Fatalf("invalid CRN without key: error = %v, want %v", err, ErrAuthConfig) + if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrConfig) { + t.Fatalf("invalid CRN without key: error = %v, want %v", err, ErrConfig) } if err := os.Unsetenv("CS_WORKSPACE_CRN"); err != nil { t.Fatal(err) @@ -525,7 +525,7 @@ func TestDeviceRefreshLockPreventsReplay(t *testing.T) { errCh <- err return } - strategy, err := ws.DeviceSession(context.Background(), WithAuthBaseURL(server.URL)) + strategy, err := ws.DeviceSession(context.Background(), WithBaseURL(server.URL)) if err != nil { errCh <- err return @@ -578,7 +578,7 @@ func TestDeviceRefreshReportsInvalidGrant(t *testing.T) { if err != nil { t.Fatal(err) } - strategy, err := ws.DeviceSession(context.Background(), WithAuthBaseURL(server.URL)) + strategy, err := ws.DeviceSession(context.Background(), WithBaseURL(server.URL)) if err != nil { t.Fatal(err) } @@ -613,7 +613,7 @@ func TestAuthRequestsIdentifyTheLibraryNotGo(t *testing.T) { t.Fatal(err) } defer profile.Close() - strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL(server.URL)) if err != nil { t.Fatal(err) } @@ -654,14 +654,14 @@ func TestAuthTransportErrorNamesTheHTTPStatusNotTheBody(t *testing.T) { t.Fatal(err) } defer profile.Close() - strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL(server.URL)) if err != nil { t.Fatal(err) } defer strategy.Close() _, err = strategy.Token(context.Background()) - if !errors.Is(err, ErrAuthTransport) { - t.Fatalf("Token error = %v, want ErrAuthTransport", err) + if !errors.Is(err, ErrTransport) { + t.Fatalf("Token error = %v, want ErrTransport", err) } if want := "cipherstash: auth transport failed: HTTP 403"; err.Error() != want { t.Fatalf("Token error = %q, want %q", err, want) @@ -680,14 +680,14 @@ func TestAuthTransportErrorWithoutAResponseNamesNoStatus(t *testing.T) { t.Fatal(err) } defer profile.Close() - strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(addr)) + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL(addr)) if err != nil { t.Fatal(err) } defer strategy.Close() _, err = strategy.Token(context.Background()) - if !errors.Is(err, ErrAuthTransport) || strings.Contains(err.Error(), "HTTP") { - t.Fatalf("Token error = %v, want a bare ErrAuthTransport", err) + if !errors.Is(err, ErrTransport) || strings.Contains(err.Error(), "HTTP") { + t.Fatalf("Token error = %v, want a bare ErrTransport", err) } } @@ -719,13 +719,13 @@ func TestOutOfRangeAuthStatusIsTransport(t *testing.T) { t.Fatal(err) } defer profile.Close() - strategy, err := profile.AccessKey(ctx, testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL("https://cts.invalid")) + strategy, err := profile.AccessKey(ctx, testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL("https://cts.invalid")) if err != nil { t.Fatal(err) } defer strategy.Close() - if _, err := strategy.Token(ctx); !errors.Is(err, ErrAuthTransport) { - t.Fatalf("Token: %v, want ErrAuthTransport", err) + if _, err := strategy.Token(ctx); !errors.Is(err, ErrTransport) { + t.Fatalf("Token: %v, want ErrTransport", err) } }) } @@ -760,7 +760,7 @@ func TestOpenWithoutProfileRunsAccessKeyAndRefusesProfileReads(t *testing.T) { } t.Setenv("CS_CLIENT_ACCESS_KEY", "CSAKtestKeyId.testKeySecret") t.Setenv("CS_WORKSPACE_CRN", testCRN) - strategy, err := store.Auto(ctx, WithAuthBaseURL(server.URL)) + strategy, err := store.Auto(ctx, WithBaseURL(server.URL)) if err != nil { t.Fatal(err) } @@ -782,7 +782,7 @@ func TestOpenWithoutProfileRunsAccessKeyAndRefusesProfileReads(t *testing.T) { func TestStrategyReportsItsStoresMemoryLockLive(t *testing.T) { ctx := context.Background() _, s := profile(t) - strategy, err := s.AccessKey(ctx, testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL("https://cts.invalid")) + strategy, err := s.AccessKey(ctx, testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL("https://cts.invalid")) if err != nil { t.Fatal(err) } diff --git a/languages/golang/stackauth/token.go b/languages/golang/auth/token.go similarity index 99% rename from languages/golang/stackauth/token.go rename to languages/golang/auth/token.go index 49dccbb3c..3e9825d6c 100644 --- a/languages/golang/stackauth/token.go +++ b/languages/golang/auth/token.go @@ -1,4 +1,4 @@ -package stackauth +package auth import ( "context" diff --git a/languages/golang/stackauth/transport.go b/languages/golang/auth/transport.go similarity index 96% rename from languages/golang/stackauth/transport.go rename to languages/golang/auth/transport.go index 0cda9cbb0..d2cea0f65 100644 --- a/languages/golang/stackauth/transport.go +++ b/languages/golang/auth/transport.go @@ -1,4 +1,4 @@ -package stackauth +package auth import ( "context" @@ -44,7 +44,7 @@ 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 ErrAuthTransport. It lives on the call's +// reaches 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 } @@ -56,11 +56,11 @@ func withAuthHTTPStatus(ctx context.Context) (context.Context, *authHTTPStatus) return context.WithValue(ctx, authHTTPStatusKey{}, status), status } -// wrap names the HTTP status of a refused exchange on an ErrAuthTransport +// 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. func (s *authHTTPStatus) wrap(err error) error { - if err == nil || !errors.Is(err, ErrAuthTransport) || s.code == 0 || (s.code >= 200 && s.code < 300) { + if err == nil || !errors.Is(err, ErrTransport) || s.code == 0 || (s.code >= 200 && s.code < 300) { return err } return fmt.Errorf("%w: HTTP %d", err, s.code) @@ -106,7 +106,7 @@ func (t *authTransport) instantiate(ctx context.Context, r wazero.Runtime) error return err } -// send is the same host import contract used by stackencrypt: four input +// send is the same host import contract used by encrypt: four input // buffers and two output slots, with a negative status on transport failure. func (t *authTransport) send(ctx context.Context, m api.Module, methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen uint32, diff --git a/languages/golang/stackauth/wasm/README.md b/languages/golang/auth/wasm/README.md similarity index 100% rename from languages/golang/stackauth/wasm/README.md rename to languages/golang/auth/wasm/README.md diff --git a/languages/golang/cmd/stashgen/README.md b/languages/golang/cmd/stashgen/README.md new file mode 100644 index 000000000..41f930570 --- /dev/null +++ b/languages/golang/cmd/stashgen/README.md @@ -0,0 +1,229 @@ +# stashgen + +`stashgen` writes the Go code that encrypts a struct with Stack Encrypt. +You declare how each field is encrypted with `stash` tags. +`stashgen` writes the encrypted type and the functions that encrypt, decrypt and search it. +Your program calls those functions, and it never builds or names a plan. + +## Use the SDK + +1. Add the generator to your module. + This needs Go 1.26 or later. + + ```sh + go get -tool github.com/cipherstash/stack/languages/golang/cmd/stashgen + ``` + +2. Put a `stash` tag on every exported field of the struct, and a `go:generate` comment beside it. + + ```go + //go:generate go tool stashgen -type User + type User struct { + _ struct{} `stash:"context=users"` + ID int64 `stash:"id,passthrough"` + Email string `stash:"email,encrypt,index=equality;match"` + Name string `stash:"name,encrypt"` + } + ``` + +3. Run the generator. + It writes `user_stash.go` beside the struct. + + ```sh + go generate ./... + ``` + +4. Commit the generated file. + +5. Call the generated functions where you write and read. + + ```go + encrypted, err := users.Encrypt(ctx, cipher, people) + opened, err := users.Decrypt(ctx, cipher, encrypted) + term, err := users.Fields.Email.Equality(ctx, cipher, "bob@example.com") + ``` + +6. Store the encrypted type. + Each sealed field is one or more byte columns: `Email.Ciphertext`, `Email.Equality`, `Email.Match`. + `encrypt.Ciphertext` and each term type implement `driver.Valuer` and `sql.Scanner`, so a database library binds and scans each one as bytes; map each one to its own column. + +7. Run the generator again after each change to the struct or to a tag. + A change to the fields of the struct stops the build until you do. + +8. In CI, run the generator and fail when a generated file changes. + + ```sh + go generate ./... && git diff --exit-code && test -z "$(git status --porcelain)" + ``` + + `git diff` sees only files git already tracks; the `git status` check also fails on a generated file that was never committed. + +The rest of this file is the reference. + +## Struct tags + +The first part of a tag is the field's name, which is the column name in a database. + +| Tag | Meaning | +|---|---| +| `` _ struct{} `stash:"context=users"` `` | the context of every field in the struct | +| `stash:"email,encrypt_into=TextEq"` | seal the field into one EQL value, with the terms that EQL type has | +| `stash:"notes,encrypt"` | seal the field, with no index | +| `stash:"email,encrypt,index=equality;match"` | seal the field, and derive each index beside it | +| `stash:"score,index=ore"` | derive the index alone; no ciphertext is stored, so `Decrypt` leaves the field at its zero value | +| `stash:"attrs,index=json"` | refused in this build: the engine does not derive the `json` index yet | +| `stash:"id,passthrough"` | store the field as it is | +| `stash:"tenant,context_field"` | the field's value is the context of every other field in the struct, in place of a `context=` tag; stored as it is | +| `stash:"-"` | leave the field out | +| `` _ struct{} `stash:"context=documents,opaque"` `` | seal the struct as one value | + +A struct has one context: either a `_ struct{}` field with `context=`, or one field with `context_field`, not both. +A `context_field` is a `string` whose value is a label such as `"tenants/acme"`, segments separated by `/`. +Every other field is sealed under that value, so each row is bound to the context it names, and a sealed value copied to another tenant's row does not decrypt there. +The field is stored as it is, in the clear and unauthenticated like a `passthrough` field, so the row names its own context when it is read. +`Decrypt` opens each row under the context it stores; `cipher.Context("tenants/acme")` names the context every row through the cipher is under, and refuses a row stored under another with `encrypt.ErrContextMismatch`. +A query on such a type derives its term under the context the cipher names, so it takes a cipher from `Context`. + +> **Take care** +> +> The name and the `context=` value are part of the encryption context of each stored value. +> If you change either one, the rows that you stored before the change do not decrypt, and queries do not find them. +> Do not change them after you store rows. No tag can rename a column and keep its context yet. + +> **Take care** +> +> Do not encrypt a decrypted value again to update its row. +> After `Decrypt`, each index-only field is zero, so `Encrypt` stores the term for zero, and a query on that field then matches the wrong rows with no error. +> To update one column, use its `Fields` entry, such as `Fields.Email.Encrypt`. + +The index names are `equality`, `match`, `ore`, `ope` and `json`. +A `match` index needs text with at least one token: the engine derives no match term for an empty string, separator-only text, or text shorter than the n-gram length (3 characters), because an empty term would match every row. +`Encrypt` then fails for the whole batch, with an error naming the row, the field and the index. +So an optional or short value does not belong under `match`: give the field `equality` alone, or make the value required. +An index takes its options in parentheses after its name, separated by commas: `index=equality;match(k=3)`. +This build refuses an index with options: a query term is derived with the default options only, so a stored term with other options would never match one. +These words are the same as the Rust API's words for the same behaviour, with two that only Go has. +`opaque` seals the whole struct as one `bytes` field holding a JSON document (see "What crosses the binding"). +`json` names the JSON index, which the Rust API does not declare yet; this build refuses it. + +An embedded struct of your own adds its tagged fields to the outer struct. +An embedded struct from another package takes one tag for all of its fields: `stash:",passthrough"` stores them as they are, and `stash:"-"` leaves them out. +Tags for other libraries, such as `gorm`, `db` and `json`, are copied to the same field of the generated type. + +An unexported field with no `stash` tag is ignored, with three notices: `stashgen` prints a warning, the generated file names the field in a comment, and the program prints a warning to stderr once for each type. +`stash:"-"` on the field states the choice, and all three stop. + +## What stashgen writes + +For `-type User`, the file `user_stash.go` holds: + +- `EncryptedUser`, with one field for each stored field of `User`, with the same name. + A passthrough field keeps its Go type. + A field with `encrypt_into` holds one EQL value. + A field with `encrypt` or `index=` holds a struct with one field for each output, such as `Email.Ciphertext` and `Email.Equality`. +- `Encrypt` and `Decrypt`, which take a slice and return a slice, and send one ZeroKMS request for each 500 sealed values in it, plus one the first time a keyset is used. +- `Fields`, with one entry for each sealed field. + An opaque struct, or a struct with no sealed field outside an opaque one, gets no `Fields`: nothing in it is sealed on its own, so nothing can be queried. + An entry encrypts one value, and it has a query method only for what the field declares: `Query` for `encrypt_into`, and `Equality`, `Match`, `Ore` or `Ope` for `index=`. +- `String` and `LogValue` on `EncryptedUser`, which print the passthrough fields and hide the sealed ones. +- A copy of the fields of `User`, so a change to them stops the build. + +Generated code uses no reflection, and no function in it panics. + +## Flags + +| Flag | Meaning | +|---|---| +| `-type T` | The struct that carries the `stash` tags. Required. | +| `-name N` | Write `EncryptN`, `DecryptN` and `NFields`. A package holds one `Encrypt`; the second struct in a package needs a name. | +| `-for P.F` | `T` declares the tags for `F`, a type in another package. Each field of `T` names a field of `F` with the same name and type, and every exported field of `F` is named. | +| `-model Name=R` | A model `R` for separate columns: one field for each column, each tagged with the output it holds, `stash:"email"` or `stash:"email,equality"`. Writes `EncryptName` and `DecryptName`. Any number. | +| `-model Name=R:D` | The same, for an `R` that cannot carry tags. The struct `D` in your package declares them. | +| `-redact` | Write `String`, `GoString` and `LogValue` methods on `T`. | +| `-output file` | The file to write. The default is the type's name in lower case, with `_stash.go`. | + +`stashgen` loads the package with `golang.org/x/tools/go/packages` and reads types, not text. +It runs none of the package's code. +It ignores its own output file when it loads the package, so a stale file does not stop it. +The same input always gives the same file: fields keep their declared order, and the file carries no version and no time. + +`stashgen` checks each declaration with the engine, and holds no copy of the engine's rules: it runs the WASI guest the SDK embeds and asks it, one field at a time, so the error names the field. +It asks the engine for the EQL types it holds: each name, its plaintext type, its indexes and its query form. +This build of the engine produces no EQL type, so `encrypt_into` is refused with "EQL types are not available yet"; the next release adds `TextEq` and `encrypt/eql`. +Separate columns work today for four indexes: `equality`, `match`, `ore` and `ope`. + +## When stashgen stops + +`stashgen` stops with an error, and writes no file, for each of these. +The error names the type and the field, and never a value. + +- an exported field with no `stash` tag; +- a tag that does not parse, or two fields with one name; +- a struct with no `context=` field and no `context_field`; +- a `context_field` beside a `context=` field, a second `context_field`, one that is not a `string`, one with another part such as `encrypt` or `index=`, or one whose name is not a label segment; +- an index or an EQL type that does not apply to the field's Go type, such as `match` on an `int32`; +- a field type that the engine cannot seal; +- an EQL type that the engine cannot produce yet; +- an index the engine does not derive yet (`json`), or an index with options; +- a `passthrough` field that has an index; +- a model with a field that has no tag, or with no field for an output; +- two structs in one package that would both write `Encrypt`; +- an embedded struct from another package with no tag; +- a struct whose every field is left out; +- a struct, slice or map field with `encrypt` or `index=` outside an opaque struct; +- a field of an opaque struct whose type JSON cannot carry both ways. + +## Declarations from a policy + +A type that a schema generates, such as a protobuf message, cannot carry tags. +For such a type, rules decide how each field is encrypted from what the schema says about it, and `stashgen.Generate` writes the same file from the rules. +The rules live in `github.com/cipherstash/stack/languages/golang/encrypt/policy`; `encrypt/policy/protosource` reads a protobuf message's fields and their options. + +```go +var category = policy.Key("classification.data_categories") + +var Individuals = policy.ForMessage(&pb.Individual{}, policy.Context("individuals"), + policy.FirstOf( + policy.When(policy.Field("id"), policy.Passthrough()), + policy.When(category.Under("user.government_id"), policy.EncryptInto("TextEq")), + policy.When(category.Under("user.contact.email"), policy.EncryptIndex(policy.Equality, policy.Match())), + policy.When(category.Under("user"), policy.Encrypt()), + ), +) + +//go:generate go run ../cmd/genencrypt +func main() { + err := stashgen.Generate(context.Background(), protosource.New(), rules.Individuals, + "../individuals/individual_stash.go") + ... +} +``` + +The first rule that matches a field decides it. +Every field needs a decision: a field no rule decides stops the generator with the field's name and its annotations. +`Name` sets the column name, and `Identity` keeps the field's context when its column is renamed. +Only a policy can set `Identity`; no tag spells it yet, because how a declaration changes over time is not decided. +The generated file goes in a package of your own, and the functions take and return pointers to the message. +`stashgen.WithName("Individual")` gives the file's names a prefix, as `-name` does: the second message generated into one package needs one, and `Generate` refuses a file whose names the package already declares. + +## Printing + +A generated type hides its sealed fields when a program prints or logs it. +The struct you wrote is not protected: `stashgen` warns when it has sealed fields and no `String` and `LogValue` methods, and the program prints the same warning to stderr once for each type. +`-redact` makes `stashgen` write those two methods on the struct, and `GoString` for `%#v`. +No warning, error or log line holds a plaintext value. + +## What crosses the binding + +Generated code sends the engine every sealed field with its value, under the declaration lowered to data: each field's label (`/`), its outputs and its wire type (`int64`, `string`, `bytes`, ...), which `stashgen` chose from the field's Go type. +A passthrough field stays on the host: the engine does nothing to it a program could observe, and the FFI codec cannot carry every Go type a program stores beside a ciphertext. +An `opaque` struct crosses as one JSON document and is one column, decoded back into the exact Go types the struct declares; a field may be any type `encoding/json` carries both ways (a scalar or a type defined over one, `[]byte`, slices, maps with string or integer keys, pointers, structs whose fields are all exported, `time.Time`). +A float in an opaque struct must be finite: `encoding/json` refuses NaN and the infinities, so `Encrypt` fails for the batch with `encrypt.ErrEncoding`. A sealed float field outside an opaque struct carries them. +A sealed field outside an opaque struct is one scalar, or a type defined over one; a struct, slice or map field seals only inside an opaque struct. +A nil `[]byte` in a sealed field, or a nil slice of a type defined over `[]byte`, comes back from `Decrypt` as an empty, non-nil slice; inside an opaque struct, nil comes back as nil. +A protobuf message with a `oneof` cannot be generated from a policy: protoc-gen-go puts its members in wrapper types, not in the message struct, and `Generate` refuses it. + +## Status + +The command runs the WASI guest the SDK embeds, so it needs the guest built: `mise run wasm:guest:build`. +The library, `github.com/cipherstash/stack/languages/golang/stashgen`, takes any `Engine`; `stashgen.Generate` takes one with `WithEngine`, and `stashgen/enginetest` has a static one for tests. diff --git a/languages/golang/cmd/stashgen/main.go b/languages/golang/cmd/stashgen/main.go new file mode 100644 index 000000000..1371a56de --- /dev/null +++ b/languages/golang/cmd/stashgen/main.go @@ -0,0 +1,103 @@ +// Command stashgen writes the encrypted type and its functions for a struct +// with stash tags. go generate runs it from the struct's package: +// +// //go:generate go tool stashgen -type User +// +// See README.md beside this file for the steps and the flag reference. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/cipherstash/stack/languages/golang/stashgen" +) + +func main() { + os.Exit(run(os.Args[1:], "", os.Stdout, os.Stderr, stashgen.GuestEngine)) +} + +// modelFlags collects the -model flags, which repeat. +type modelFlags []stashgen.ModelRequest + +func (m *modelFlags) String() string { + parts := make([]string, len(*m)) + for i, r := range *m { + parts[i] = r.Name + "=" + r.Type + if r.Declares != "" { + parts[i] += ":" + r.Declares + } + } + return strings.Join(parts, " ") +} + +func (m *modelFlags) Set(s string) error { + r, err := stashgen.ParseModelFlag(s) + if err != nil { + return err + } + *m = append(*m, r) + return nil +} + +// 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. +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) + req := stashgen.Request{Dir: dir} + var models modelFlags + fs.StringVar(&req.Type, "type", "", "the struct that carries the stash tags (required)") + fs.StringVar(&req.Name, "name", "", "write EncryptN, DecryptN and NFields instead of Encrypt, Decrypt and Fields") + fs.StringVar(&req.For, "for", "", "P.F: -type declares the tags for F, a type in package P") + fs.Var(&models, "model", "Name=R or Name=R:D: a model R for separate columns; writes EncryptName and DecryptName (repeatable)") + fs.BoolVar(&req.Redact, "redact", false, "write String, GoString and LogValue methods on the -type struct") + fs.StringVar(&req.Output, "output", "", "the file to write (default: the type's name in lower case, with _stash.go)") + fs.Usage = func() { + fmt.Fprintln(stderr, "usage: stashgen -type T [-name N] [-for P.F] [-model Name=R[:D]]... [-redact] [-output file]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 2 + } + return 2 + } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "stashgen: unexpected argument %q\n", fs.Arg(0)) + fs.Usage() + return 2 + } + if req.Type == "" { + fmt.Fprintln(stderr, "stashgen: -type is required") + fs.Usage() + return 2 + } + req.Models = models + + ctx := context.Background() + engine, err := newEngine(ctx) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + file, err := stashgen.FromTags(ctx, engine, req) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + for _, n := range file.Notices { + fmt.Fprintln(stderr, n) + } + if err := file.Write(); err != nil { + fmt.Fprintf(stderr, "stashgen: write %s: %v\n", file.Path, err) + return 1 + } + return 0 +} diff --git a/languages/golang/cmd/stashgen/main_test.go b/languages/golang/cmd/stashgen/main_test.go new file mode 100644 index 000000000..274ca0717 --- /dev/null +++ b/languages/golang/cmd/stashgen/main_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/stashgen" + "github.com/cipherstash/stack/languages/golang/stashgen/enginetest" +) + +func fake(context.Context) (stashgen.Engine, error) { return enginetest.Static{}, nil } + +// writeModule writes a module that resolves the SDK to the generator's stub. +func writeModule(t *testing.T, files map[string]string) string { + t.Helper() + stubs, err := filepath.Abs(filepath.Join("..", "..", "stashgen", "testdata")) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + gomod := "module example.com/app\n\ngo 1.26\n\nrequire github.com/cipherstash/stack/languages/golang v0.0.0\n\nreplace github.com/cipherstash/stack/languages/golang => " + filepath.Join(stubs, "stubsdk") + "\n" + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o600); err != nil { + t.Fatal(err) + } + for name, src := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(src), 0o600); err != nil { + t.Fatal(err) + } + } + return dir +} + +const userSource = `package users + +type User struct { + _ struct{} ` + "`stash:\"context=users\"`" + ` + ID int64 ` + "`stash:\"id,passthrough\"`" + ` + Email string ` + "`stash:\"email,encrypt_into=TextEq\"`" + ` + cache string +} +` + +func TestRunWritesTheFileAndPrintsTheNotices(t *testing.T) { + dir := writeModule(t, map[string]string{"model.go": userSource}) + var stdout, stderr bytes.Buffer + if code := run([]string{"-type", "User"}, dir, &stdout, &stderr, fake); code != 0 { + t.Fatalf("exit %d\n%s", code, stderr.String()) + } + out, err := os.ReadFile(filepath.Join(dir, "user_stash.go")) + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(out, []byte("// Code generated by stashgen. DO NOT EDIT.\n")) { + t.Fatalf("output starts with %q", out[:40]) + } + for _, want := range []string{ + `stashgen: User: not encrypted and not stored: the unexported field "cache". Tag it ` + "`stash:\"-\"`" + ` to confirm that.`, + "stashgen: User prints its sealed fields in the clear: it has no String or LogValue method. Write them, or run stashgen with -redact.", + } { + if !strings.Contains(stderr.String(), want) { + t.Errorf("stderr lacks %q:\n%s", want, stderr.String()) + } + } + if stdout.Len() != 0 { + t.Errorf("stdout = %q, want nothing", stdout.String()) + } + + // A second run over the stale file writes the same bytes. + stderr.Reset() + if code := run([]string{"-type", "User", "-redact"}, dir, &stdout, &stderr, fake); code != 0 { + t.Fatalf("second run: exit %d\n%s", code, stderr.String()) + } + again, err := os.ReadFile(filepath.Join(dir, "user_stash.go")) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(again, []byte("func (u User) String() string")) { + t.Fatal("-redact did not write String on User") + } + if strings.Contains(stderr.String(), "prints its sealed fields") { + t.Errorf("-redact still warns about printing:\n%s", stderr.String()) + } +} + +func TestRunFlags(t *testing.T) { + dir := writeModule(t, map[string]string{"model.go": userSource}) + cases := []struct { + args []string + code int + want string + }{ + {nil, 2, "-type is required"}, + {[]string{"-type"}, 2, "flag needs an argument"}, + {[]string{"-type", "User", "extra"}, 2, `unexpected argument "extra"`}, + {[]string{"-type", "User", "-model", "Rows"}, 2, "is not Name=R or Name=R:D"}, + {[]string{"-type", "User", "-model", "rows=R"}, 2, "must be an exported Go name"}, + {[]string{"-type", "Nobody"}, 1, "has no type Nobody"}, + {[]string{"-type", "User", "-output", "custom_stash.go"}, 0, ""}, + {[]string{"-h"}, 2, "usage: stashgen"}, + } + for _, c := range cases { + var stdout, stderr bytes.Buffer + code := run(c.args, dir, &stdout, &stderr, fake) + if code != c.code { + t.Errorf("%v: exit %d, want %d\n%s", c.args, code, c.code, stderr.String()) + } + if !strings.Contains(stderr.String(), c.want) { + t.Errorf("%v: stderr %q lacks %q", c.args, stderr.String(), c.want) + } + } + if _, err := os.Stat(filepath.Join(dir, "custom_stash.go")); err != nil { + t.Errorf("-output: %v", err) + } +} + +// The real engine: the embedded guest, which produces no EQL type in this +// build, so a struct with encrypt_into is refused and nothing is written. +// Skips when the guest is not built. +func TestRunAsksTheEmbeddedEngine(t *testing.T) { + checker, err := encrypt.NewChecker(context.Background()) + if err != nil { + t.Skip(err) + } + _ = checker.Close() + dir := writeModule(t, map[string]string{"model.go": userSource}) + var stdout, stderr bytes.Buffer + if code := run([]string{"-type", "User"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 1 { + t.Fatalf("exit %d, want 1\n%s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "EQL types are not available yet") { + t.Fatalf("stderr = %q", stderr.String()) + } + if _, err := os.Stat(filepath.Join(dir, "user_stash.go")); !os.IsNotExist(err) { + t.Fatal("a file was written after the engine refused") + } + // Separate columns are what the engine runs today. + columns := strings.Replace(userSource, "encrypt_into=TextEq", "encrypt,index=equality;match", 1) + dir = writeModule(t, map[string]string{"model.go": columns}) + stderr.Reset() + if code := run([]string{"-type", "User"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 0 { + t.Fatalf("exit %d\n%s", code, stderr.String()) + } +} + +func TestModelFlagsRepeat(t *testing.T) { + var m modelFlags + for _, s := range []string{"Rows=ContactRow", "Legacy=userdb.Contact:contactRow"} { + if err := m.Set(s); err != nil { + t.Fatal(err) + } + } + if got := m.String(); got != "Rows=ContactRow Legacy=userdb.Contact:contactRow" { + t.Fatalf("String = %q", got) + } +} diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md new file mode 100644 index 000000000..d9c5dd177 --- /dev/null +++ b/languages/golang/encrypt/README.md @@ -0,0 +1,251 @@ +# Stack Encrypt for Go + +Searchable, field-level encryption for Go structs under per-value ZeroKMS +data keys. You declare what to encrypt with struct tags; `stashgen` writes the +encrypted type and the functions that encrypt, decrypt and search it; the +`stack-encrypt` Rust engine runs unmodified inside a WASI module embedded in +this package, through [wazero], with no cgo. + +The package reference is on [pkg.go.dev]; the generator's reference is +[`cmd/stashgen/README.md`](../cmd/stashgen/README.md). + +[wazero]: https://wazero.io +[pkg.go.dev]: https://pkg.go.dev/github.com/cipherstash/stack/languages/golang/encrypt + +## Use the SDK + +1. Add the generator to your module. This needs Go 1.26 or later. + + ```sh + go get -tool github.com/cipherstash/stack/languages/golang/cmd/stashgen + ``` + +2. Put a `stash` tag on every exported field of the struct, and a `go:generate` + comment beside it. + + ```go + //go:generate go tool stashgen -type User + type User struct { + _ struct{} `stash:"context=users"` + ID int64 `stash:"id,passthrough"` + Email string `stash:"email,encrypt,index=equality;match"` + Age uint32 `stash:"age,encrypt,index=equality;ore"` + } + ``` + +3. Run the generator. It writes `user_stash.go` beside the struct. + + ```sh + go generate ./... + ``` + +4. Commit the generated file. + +5. Call the generated functions where you write and read. + + ```go + client, err := encrypt.NewClient(ctx) + if err != nil { + return err + } + defer client.Close() + cipher := client.Keyset(encrypt.KeysetName("tenant-42")) + + encrypted, err := users.Encrypt(ctx, cipher, people) + opened, err := users.Decrypt(ctx, cipher, encrypted) + term, err := users.Fields.Email.Equality(ctx, cipher, "bob@example.com") + ``` + + `Encrypt` and `Decrypt` send one ZeroKMS request for each 500 sealed + values in the batch, plus one request the first time a keyset is used. + +6. Store the encrypted type. Each sealed field is one or more byte columns: + `e.Email.Ciphertext`, `e.Email.Equality`, `e.Email.Match`. `Ciphertext` + and each term type implement `driver.Valuer` and `sql.Scanner`, so a + database library binds and scans each column as bytes; map each one to + its own column. + +7. Run the generator again after each change to the struct or to a tag. A + change to the fields of the struct stops the build until you do. + +8. In CI, run the generator and fail when a generated file changes. + + ```sh + go generate ./... && git diff --exit-code && test -z "$(git status --porcelain)" + ``` + + `git diff` sees only files git already tracks; the `git status` check + also fails on a generated file that was never committed. + +The rest of this file is the reference. [`example/`](example/) is the eight +steps as a program. + +## Connect + +A `Client` is one ZeroKMS client: its client key, its default keyset, and the +keysets it has loaded since. Make one per process and share it; it is safe +for concurrent use. + +```go +client, err := encrypt.NewClient(ctx) +if err != nil { + return err // encrypt.ErrNoCredentials: nothing configured +} +defer client.Close() +``` + +`ctx` is Go's `context.Context`: the deadline and cancellation for the one +ZeroKMS round trip `NewClient` makes. It has nothing to do with an +*encryption* context, which is what a field is sealed under; that is the +`context=` tag. + +Every other setting is a functional option with a default: + +```go +client, err := encrypt.NewClient(ctx, + encrypt.WithCredentials(encrypt.OIDCFederation(crn, provider)), + encrypt.WithTransport(rt), + encrypt.WithKeysetCacheSize(64), + encrypt.WithRequireLockedMemory(), +) +``` + +### Credentials + +`AutoCredentials`, the default, reads the environment first +(`CS_CLIENT_ACCESS_KEY` and `CS_WORKSPACE_CRN` for the token, `CS_CLIENT_ID` +and `CS_CLIENT_KEY` for the key), then the developer profile `stash auth login` +writes, through the [`auth`](../auth) package. `NewCredentials` takes a client +id, a client key and an `auth` strategy explicitly; `OIDCFederation` mints the +token from an identity provider's. No credentials take a raw token. The client +key is consumed by `NewClient` and wiped, whatever the outcome. + +## The cipher + +A `Cipher` is the client bound to one keyset, and to any extension of the +context. Every generated function takes one. + +```go +cipher := client.Keyset(encrypt.KeysetName("tenant-42")).Extend("tenant-42") +``` + +`Extend` appends to the context that the tags declare, for every field, in +every call through the cipher: the write, the query and the read. A row +written through `Extend("tenant-42")` opens and matches only through a cipher +with the same extension. No call takes a keyset or a context, so the three +cannot use different ones. + +> **Take care** +> +> The context binds each value to its table, its column and the cipher's +> extension. It does not bind the value to its row, so a value copied to +> another row of the same table decrypts there with no error. To bind each +> row to a context of its own, such as its tenant, give the struct a +> `context_field` instead of a `context=` tag: +> +> ```go +> type Note struct { +> Tenant string `stash:"tenant,context_field"` +> Text string `stash:"text,encrypt,index=equality"` +> } +> ``` +> +> The field's value is the context every other field is sealed under, a +> label such as `"tenants/acme"`. It is stored as it is, in the clear and +> unauthenticated like a passthrough field, so the row names its own +> context. A row's sealed values open only under the context the row +> stores: a value copied to another tenant's row does not decrypt there. +> `Encrypt` and `Decrypt` take each row's context from the row. +> `cipher.Context("tenants/acme")` names the context every row through the +> cipher is under: `Decrypt` then refuses a row stored under another tenant +> with `ErrContextMismatch`, before any key is retrieved, and `Encrypt` +> refuses a value whose field says otherwise. A query on such a type derives +> its term under the context the cipher names, and fails without one. + +`Client.DefaultKeyset()` is the keyset a ZeroKMS administrator set for the +client; `Client.Keyset(encrypt.KeysetName(..))` or `Client.Keyset(id)` any +other, loaded on first use. `Cipher.KeysetID(ctx)` resolves it. + +## Reading + +`users.Decrypt` takes a `Decrypter`: the `*Cipher`, which refuses a row +another keyset sealed with `ErrForeignKeyset` before any key is retrieved, or +the `*Client`, which opens each row under the keyset that sealed it. The +`*Client` opens only rows that a cipher with no extension sealed; a row +written through `Extend` opens only through a cipher with the same extension. +For a type with a `context_field`, the `*Cipher` from `cipher.Context(..)` +also refuses a row whose stored context is another, with +`ErrContextMismatch`; the `*Client` opens each row under the context it +stores. + +## What is stored + +A field with `encrypt` and `index=` is a struct with one field for each +output: `Ciphertext` (`encrypt.Ciphertext`, the frozen stack-encrypt leaf), +and `Equality`, `Match`, `Ore` or `Ope` (the term types). A field with +`encrypt` alone has `Ciphertext` only. A passthrough field keeps its Go type. +An `opaque` struct is one `Sealed` field. Every stored type implements +`driver.Valuer` and `sql.Scanner`. + +A `match` index needs text with at least one token. The engine derives no +match term for an empty string, separator-only text, or text shorter than the +n-gram length (3 characters), because an empty term would match every row. +`Encrypt` then fails for the whole batch with `ErrTerm`, naming the row, the +field and the index. So an optional or short value does not belong under +`match`: give such a field `equality` alone, or make the value required. + +Terms are byte-equal to the ones the Rust crate derives, so a term from +`users.Fields.Email.Equality` compares against a stored term written from any +language. `EqualityTerm.Equal` compares in constant time; `OreTerm.Compare` +and `OpeTerm.Compare` order as the plaintexts; `MatchTerm.Positions` decodes +the token positions. + +A database can compare some terms by itself: + +- `Equality`: compare with `=`. +- `Ope`: compare with `<`, `>` and `ORDER BY`. The byte order is the + plaintext order. +- `Ore`: do not compare in SQL. Plain byte order is almost always right, and + sometimes wrong, with no error. Compare in Go with `OreTerm.Compare`, or + use an EQL type (`encrypt_into=`), which the database compares correctly. +- `Match`: do not compare in SQL. Decode the positions in Go with + `MatchTerm.Positions`, or use an EQL type. + +## Errors + +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`: + +- `ErrForeignKeyset`: a `*Cipher` got a row another keyset sealed. +- `ErrContextMismatch`: a `*Cipher` with a `Context` got a row whose + `context_field` names another context, or a value whose field does. +- `ErrForbidden`, `ErrAuthentication`: a ciphertext that does not open under + its field's context (ZeroKMS refuses the key, or the AEAD fails). +- `ErrEncoding`: a stored value or a call that does not fit the declaration. +- `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. + +## Key material + +Every key the guest holds lives in the guest's linear memory, which this +package supplies: reserved once, locked in RAM and excluded from core dumps +where the platform allows, and wiped before it is released. The lock is best +effort (`RLIMIT_MEMLOCK` is 64 KiB on many Linux hosts); `Client.MemoryLocked` +reports it, `Client.MemoryLockError` says why not, and +`WithRequireLockedMemory` makes `NewClient` refuse to start unlocked. See the +package documentation for the full account. + +## Building + +The package embeds `wasm/stack_encrypt_guest.wasm`, a build artefact of the +Rust crate in [`guest/`](guest/). It is not committed: run +`mise run wasm:guest:build` (and `mise run wasm:auth-guest:build` for the +credential guest) before `go test`, and +`mise run wasm:guest:build:deterministic` for the test build the hermetic +round-trip and fixture tests use. Without the builds `NewClient` returns +`ErrGuestNotBuilt` and the tests skip. diff --git a/languages/golang/encrypt/checker.go b/languages/golang/encrypt/checker.go new file mode 100644 index 000000000..7c0a0ee4d --- /dev/null +++ b/languages/golang/encrypt/checker.go @@ -0,0 +1,206 @@ +package encrypt + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/record" + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// Checker asks the embedded engine about declarations, with no credentials, +// no keyset and no network: what stashgen uses to refuse a declaration the +// engine would refuse, and to learn which EQL types the engine produces. It +// holds a guest instance that was never given a client key, so every other +// operation on it is [ErrState]. +type Checker struct { + c *Client +} + +// NewChecker instantiates the embedded guest for the generator's questions. +func NewChecker(ctx context.Context) (*Checker, error) { + wasm, err := embeddedGuest() + if err != nil { + return nil, err + } + t := &transport{rt: refusingTransport{}, token: noToken{}} + inst, err := newInstance(ctx, wasm, t, guest.BestEffort) + if err != nil { + return nil, err + } + return &Checker{c: newClient(inst, t)}, nil +} + +// Close releases the guest. +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. +func (k *Checker) Check(ctx context.Context, plan *record.Plan) error { + if err := plan.Validate(); err != nil { + return fmt.Errorf("%w: %v", ErrEncoding, err) + } + encoded, err := vcffi.Marshal(plan.Wire()) + if err != nil { + return fmt.Errorf("%w: %v", ErrEncoding, err) + } + _, err = k.c.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.planCheck, buf(encoded)) + }) + return err +} + +// Targets lists the EQL types this build of the engine produces: none until +// the EQL target dispatch lands. +func (k *Checker) Targets(ctx context.Context) ([]record.Target, error) { + out, err := k.c.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.targets) + }) + if err != nil { + return nil, err + } + decoded, err := vcffi.Unmarshal(out) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInternal, err) + } + return parseTargets(decoded) +} + +// parseTargets reads se_targets's decoded value: {"targets": [entry, ...]}, +// each entry an object with exactly the keys record.Target documents. The +// shape is a contract between eql-bindings' serialiser and this reader, so +// an unknown key, a missing name, or a value of the wrong type is +// ErrInternal: a decoder that kept a zero value would let stashgen decide an +// encrypt_into on a type with no kind or no indexes. +func parseTargets(decoded any) ([]record.Target, error) { + obj, ok := decoded.(vcvalue.Object) + if !ok || len(obj) != 1 || obj[0].Key != "targets" { + return nil, fmt.Errorf("%w: se_targets returned %T, not {\"targets\": [...]}", ErrInternal, decoded) + } + items, ok := obj[0].Value.([]any) + if !ok { + return nil, fmt.Errorf("%w: se_targets returned %T for the list", ErrInternal, obj[0].Value) + } + targets := make([]record.Target, 0, len(items)) + for i, item := range items { + entry, ok := item.(vcvalue.Object) + if !ok { + return nil, fmt.Errorf("%w: target %d came back as %T", ErrInternal, i, item) + } + t, err := parseTarget(entry) + if err != nil { + return nil, fmt.Errorf("%w: target %d: %v", ErrInternal, i, err) + } + targets = append(targets, t) + } + return targets, nil +} + +func parseTarget(entry vcvalue.Object) (record.Target, error) { + var t record.Target + seen := map[string]bool{} + for _, f := range entry { + if seen[f.Key] { + return t, fmt.Errorf("key %q twice", f.Key) + } + seen[f.Key] = true + var err error + switch f.Key { + case "name": + t.Name, err = targetString(f) + case "family": + t.Family, err = targetString(f) + case "suffix": + t.Suffix, err = targetString(f) + case "plaintext": + var kind string + kind, err = targetOptionalString(f) + t.Plaintext = record.Kind(kind) + if err == nil && !t.Plaintext.Known() { + err = fmt.Errorf("plaintext %q is not a kind", kind) + } + case "sql_domain": + t.SQLDomain, err = targetString(f) + case "indexes": + list, ok := f.Value.([]any) + if !ok { + return t, fmt.Errorf("indexes is %T, not a list", f.Value) + } + for _, item := range list { + s, ok := item.(string) + if !ok { + return t, fmt.Errorf("an index is %T, not a string", item) + } + switch o := record.Output(s); o { + // "json" is the SteVec document index, which no plan output + // carries. + case record.Equality, record.Match, record.Ore, record.Ope, "json": + t.Indexes = append(t.Indexes, o) + default: + return t, fmt.Errorf("unknown index %q", s) + } + } + case "query": + t.Query, err = targetOptionalString(f) + case "query_sql_domain": + t.QuerySQLDomain, err = targetOptionalString(f) + case "producible": + b, ok := f.Value.(bool) + if !ok { + return t, fmt.Errorf("producible is %T, not a bool", f.Value) + } + t.Producible = b + case "reason": + t.Reason, err = targetOptionalString(f) + default: + return t, fmt.Errorf("unknown key %q", f.Key) + } + if err != nil { + return t, err + } + } + if t.Name == "" { + return t, errors.New("no name") + } + for _, key := range []string{"indexes", "producible"} { + if !seen[key] { + return t, fmt.Errorf("no %s", key) + } + } + return t, nil +} + +func targetString(f vcvalue.Field) (string, error) { + s, ok := f.Value.(string) + if !ok { + return "", fmt.Errorf("%s is %T, not a string", f.Key, f.Value) + } + return s, nil +} + +func targetOptionalString(f vcvalue.Field) (string, error) { + if f.Value == nil { + return "", nil + } + return targetString(f) +} + +// refusingTransport fails every request: the checker makes none. +type refusingTransport struct{} + +func (refusingTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("encrypt: the checker makes no request") +} + +// noToken has no token: the checker needs none. +type noToken struct{} + +func (noToken) Token(context.Context) (string, error) { + return "", errors.New("encrypt: the checker has no credentials") +} diff --git a/languages/golang/encrypt/cipher.go b/languages/golang/encrypt/cipher.go new file mode 100644 index 000000000..04c767141 --- /dev/null +++ b/languages/golang/encrypt/cipher.go @@ -0,0 +1,104 @@ +package encrypt + +import ( + "context" + "fmt" + + "github.com/cipherstash/stack/languages/golang/internal/record" +) + +// Cipher is a [Client] bound to one keyset, and to any extension of the +// context: the Go form of the Rust crate's KeysetCipher. Generated code seals +// values, derives terms and opens records through it; a program never calls +// the engine directly. A Cipher opens only its own keyset's ciphertexts — a +// leaf sealed under another keyset is refused as [ErrForeignKeyset] before +// any key is retrieved. To open ciphertexts from any keyset, pass the +// [Client] where a [Decrypter] is taken. A Client opens only rows that a +// cipher with no extension encrypted; rows sealed through [Cipher.Extend] +// open only through a cipher with the same extension. +// +// A Cipher holds no guest state: the keyset is selected on every call, and +// loaded by the guest on first use, so one is cheap to make per request or +// per tenant. +type Cipher struct { + client *Client + keyset KeysetSelector + extension []any + // context is the label Context named, for a type whose context is one + // of its fields; "" names none. + context string + // err is a refused extension part or context, reported by the first + // call rather than by Extend or Context: a cipher is made without a + // request, and no call on this package panics. + err error +} + +// Client is the client this cipher belongs to. +func (cph *Cipher) Client() *Client { return cph.client } + +// Keyset is the selector this cipher is bound to. +func (cph *Cipher) Keyset() KeysetSelector { return cph.keyset } + +// KeysetID resolves the cipher's keyset to its id: Rust's +// KeysetCipher::keyset_id, and in Go the one explicit resolution point, +// since a Cipher is made without a request. The first use of a name or id +// on the client is one ZeroKMS round trip, later uses come from the +// guest's cache; the default keyset never makes a request. Use it at boot +// to validate a tenant's keyset and learn its id. +func (cph *Cipher) KeysetID(ctx context.Context) (KeysetID, error) { + return cph.client.resolveKeyset(ctx, cph.keyset) +} + +// Extend returns a cipher that extends the context of every field, in every +// call through it: the write, the query and the read. It appends to the +// context the tags declare and never replaces it, so a field sealed through +// cipher.Extend("tenant-42") opens and matches only through a cipher with +// the same extension. A part is a string, a byte slice or an integer +// (int32, int64, uint32, uint64; Go's int is sent as int64); a byte slice is +// copied. Several parts nest in order: Extend(a, b) is Extend(a).Extend(b). +// An unsupported part type is reported by the first call through the +// cipher, as [ErrEncoding]. +func (cph *Cipher) Extend(parts ...any) *Cipher { + next := &Cipher{client: cph.client, keyset: cph.keyset, context: cph.context, err: cph.err} + next.extension = append(next.extension, cph.extension...) + for _, part := range parts { + if err := record.CheckPart(part); err != nil && next.err == nil { + next.err = fmt.Errorf("%w: Extend: %v", ErrEncoding, err) + } + next.extension = append(next.extension, part) + } + return next +} + +// Extension is the parts the cipher extends every field's context by, in +// order. Empty for a cipher straight from [Client.Keyset]. +func (cph *Cipher) Extension() []any { + return append([]any(nil), cph.extension...) +} + +// Context returns a cipher that names the context every row through it is +// under, for a type whose context is one of its own fields (a field tagged +// `context_field`): the Rust chain's `.context(..)`. Through such a cipher, +// Decrypt checks each row's stored context field against the label before +// any key is retrieved and refuses a row that names another with +// [ErrContextMismatch]; Encrypt refuses a value whose context field says +// otherwise the same way, before anything is sent; a query derives its term +// under the label, which such a type cannot derive otherwise; and a field's +// own Encrypt seals the one value under it. Without a Context, Encrypt and +// Decrypt take each row's context from the row, and a query has no context +// to derive under. A type with a `context=` tag has its context already, so +// every call through a cipher with a Context refuses it as [ErrEncoding]. +// The label is segments separated by '/', such as "tenants/acme"; one that +// is not a plain label is reported by the first call, as ErrEncoding. +func (cph *Cipher) Context(label string) *Cipher { + next := &Cipher{client: cph.client, keyset: cph.keyset, context: label, err: cph.err} + next.extension = append(next.extension, cph.extension...) + if _, err := record.ParseContext(label); err != nil && next.err == nil { + next.err = fmt.Errorf("%w: Context: %v", ErrEncoding, err) + } + return next +} + +// ContextLabel is the label [Cipher.Context] named, or "" for a cipher +// that names none. +func (cph *Cipher) ContextLabel() string { return cph.context } diff --git a/languages/golang/encrypt/ciphertext.go b/languages/golang/encrypt/ciphertext.go new file mode 100644 index 000000000..242d85d67 --- /dev/null +++ b/languages/golang/encrypt/ciphertext.go @@ -0,0 +1,86 @@ +package encrypt + +import ( + "database/sql/driver" + "fmt" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" +) + +// Ciphertext is one sealed field as a database column holds it: the frozen +// stack-encrypt leaf encoding (version, keyset id, IV, ZeroKMS tag, +// ciphertext). A generated type holds one for each field sealed in separate +// columns, beside that field's terms. It is a distinct type from vitaminc's +// own leaf on purpose: a stack-encrypt leaf is not decryptable by +// vitaminc-encrypt and must never scan or marshal where one belongs. +// +// A sealed field is one scalar — a string, a number, a bool or a []byte, or +// a type defined over one — and seals as vitaminc's tagged leaf, which a +// Rust record opens when its field is a Value (not a bare u32 or String). A struct, slice or map seals only as part of an opaque struct, +// which crosses as one JSON document and is one leaf; stashgen refuses it +// anywhere else, because the engine would seal it as a tree of leaves and a +// column holds one. +type Ciphertext []byte + +// Value implements driver.Valuer, binding the leaf as a byte column. +func (c Ciphertext) Value() (driver.Value, error) { return []byte(c), nil } + +// Scan implements sql.Scanner, loading a leaf from a byte column. +func (c *Ciphertext) Scan(src any) error { + b, err := scanBytes("Ciphertext", src) + *c = b + return err +} + +// scanBytes copies a driver byte value: drivers may reuse the source slice +// after Scan returns. +func scanBytes(kind string, src any) ([]byte, error) { + switch v := src.(type) { + case []byte: + out := make([]byte, len(v)) + copy(out, v) + return out, nil + case string: + return []byte(v), nil + case nil: + return nil, fmt.Errorf("encrypt: cannot scan NULL into %s", kind) + default: + return nil, fmt.Errorf("encrypt: cannot scan %T into %s", src, kind) + } +} + +// otherLeaf is a leaf of a kind the SDK does not store: the authenticated +// markers for an absent value, an empty sequence and an empty map, which a +// record field never produces. Decoding one is an error at the use site. +type otherLeaf struct { + kind vcffi.LeafKind + bytes []byte +} + +// leaves is the vcffi.LeafSet of this binding's leaf types. +var leaves = vcffi.LeafSet{ + Classify: func(v any) (vcffi.LeafKind, []byte, bool) { + switch n := v.(type) { + case Ciphertext: + return vcffi.LeafSingle, n, true + case otherLeaf: + return n.kind, n.bytes, true + default: + return 0, nil, false + } + }, + Make: func(kind vcffi.LeafKind, bytes []byte) any { + if kind == vcffi.LeafSingle { + return Ciphertext(bytes) + } + return otherLeaf{kind: kind, bytes: bytes} + }, +} + +func marshalCipherText(v any) ([]byte, error) { + return vcffi.MarshalCipherText(leaves, v) +} + +func unmarshalCipherText(buf []byte) (any, error) { + return vcffi.UnmarshalCipherText(leaves, buf) +} diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/encrypt/client.go similarity index 86% rename from languages/golang/stackencrypt/client.go rename to languages/golang/encrypt/client.go index 8482cb573..361106ada 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/encrypt/client.go @@ -1,4 +1,4 @@ -package stackencrypt +package encrypt import ( "context" @@ -100,7 +100,7 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) if explicit, ok := creds.(*explicitCredentials); ok && err == nil && explicit.token == nil { // Knowable from the credentials as they were built: the one place // a missing token source is decided. - err = fmt.Errorf("%w: NewCredentials needs a stackauth strategy for the token", ErrEncoding) + err = fmt.Errorf("%w: NewCredentials needs an auth strategy for the token", ErrEncoding) } wasm := cfg.guest if err == nil && wasm == nil { @@ -144,7 +144,7 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) // here unlocked; NewCredentials' store is the caller's, opened // however the caller chose. if lockErr := resolved.MemoryLockError(); lockErr != nil { - return nil, fmt.Errorf("stackencrypt: the credentials' memory: %w", lockErr) + return nil, fmt.Errorf("encrypt: the credentials' memory: %w", lockErr) } } encoded, err := encodeConfig(initConfig{ @@ -180,7 +180,7 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) }) if err != nil { _ = c.Close() - return nil, fmt.Errorf("stackencrypt: cipher init: %w", err) + return nil, fmt.Errorf("encrypt: cipher init: %w", err) } if len(out) != len(KeysetID{}) { _ = c.Close() @@ -224,7 +224,7 @@ func newClient(inst *instance, t *transport) *Client { // MemoryLocked reports whether the memory the client's key material lives // in — this guest's, where the client key and every loaded index key are, -// and whatever the credentials held it in on the way (stackauth's +// and whatever the credentials held it in on the way (auth's // credential guest, for [AutoCredentials]) — is locked in RAM and, on // Linux, excluded from core dumps. False means a lock was refused (on // Linux, most often RLIMIT_MEMLOCK, which defaults to 64 KiB on many @@ -264,7 +264,7 @@ func (c *Client) memoryState() string { // printed. The state is what an operator reading a startup log needs to // see, and [Client.LogValue] gives it structured form. func (c *Client) String() string { - return fmt.Sprintf("stackencrypt.Client{memory: %s}", c.memoryState()) + return fmt.Sprintf("encrypt.Client{memory: %s}", c.memoryState()) } // LogValue implements slog.LogValuer: a group with memory_locked and, when @@ -289,7 +289,7 @@ type initConfig struct { // client key; the caller wipes it, and the key it was read from. func encodeConfig(cfg initConfig) ([]byte, error) { if cfg.clientID == "" || cfg.clientKey.IsZero() { - return nil, errors.New("stackencrypt: the credentials' client id and client key are required") + return nil, errors.New("encrypt: the credentials' client id and client key are required") } // The key crosses as text: the guest's config parser takes the hex or // base64 form as the CS_CLIENT_KEY variable and secretkey.json hold it. @@ -342,11 +342,12 @@ func (c *Client) Close() error { // StackCipher::keyset. No request is made here — Go has no await, so the // keyset is resolved by the guest on the cipher's first use (and cached), // which makes a Cipher cheap to make per call, per tenant or per request. -// [Cipher.KeysetID] is the explicit resolution point. A nil selector is a -// programming error and panics; the default keyset is [Client.DefaultKeyset]. +// [Cipher.KeysetID] is the explicit resolution point. A nil selector gives a +// cipher whose every call fails with [ErrEncoding]; the default keyset is +// [Client.DefaultKeyset]. func (c *Client) Keyset(sel KeysetSelector) *Cipher { if sel == nil { - panic("stackencrypt: Client.Keyset(nil); the default keyset is Client.DefaultKeyset") + return &Cipher{client: c, keyset: defaultKeyset{}, err: fmt.Errorf("%w: Client.Keyset(nil); the default keyset is Client.DefaultKeyset", ErrEncoding)} } return &Cipher{client: c, keyset: sel} } @@ -381,33 +382,6 @@ func (c *Client) resolveKeyset(ctx context.Context, sel KeysetSelector) (KeysetI return id, nil } -// Decrypt opens a ciphertext produced by any keyset of this client: each -// leaf is opened under the keyset it was sealed with, with batched key -// retrievals per keyset (one per 500 leaves sealed under it). ct is the -// shape Cipher.Encrypt returns; aad must be what the value was sealed -// under. -func (c *Client) Decrypt(ctx context.Context, ct any, aad []byte) (any, error) { - return c.decryptValue(ctx, anyKeyset{}, ct, aad, false) -} - -// DecryptElement is Decrypt for a value sealed as a sequence element; see -// Cipher.DecryptElement. -func (c *Client) DecryptElement(ctx context.Context, ct any, aad []byte) (any, error) { - return c.decryptValue(ctx, anyKeyset{}, ct, aad, true) -} - -// DecryptRecords opens records produced by Cipher.EncryptRecords under any -// keyset of this client, into a slice; see Cipher.DecryptRecords. -func (c *Client) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...RecordOption) error { - return c.decryptRecords(ctx, anyKeyset{}, records, out, opts) -} - -// DecryptRecord opens one record under any keyset of this client; see -// Cipher.DecryptRecord. -func (c *Client) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...RecordOption) error { - return c.decryptRecord(ctx, anyKeyset{}, record, out, opts) -} - // call runs f on the instance under the client's lock. // // A call interrupted by its context (the runtime closes the module on a @@ -462,27 +436,3 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([ } return out, nil } - -func (c *Client) decryptValue(ctx context.Context, sel KeysetSelector, ct any, aad []byte, element bool) (any, error) { - encoded, err := marshalCipherText(ct) - if err != nil { - return nil, err - } - opts, err := vcffi.Marshal(options(sel)) - if err != nil { - return nil, err - } - out, err := c.call(ctx, func(inst *instance) ([]byte, error) { - fn := inst.decrypt - if element { - fn = inst.decryptElement - } - return inst.call(ctx, fn, buf(encoded), buf(aad), buf(opts)) - }) - if err != nil { - return nil, err - } - // The output is plaintext: decode, then wipe the transport copy. - defer wipe(out) - return vcffi.Unmarshal(out) -} diff --git a/languages/golang/stackencrypt/clientkey.go b/languages/golang/encrypt/clientkey.go similarity index 82% rename from languages/golang/stackencrypt/clientkey.go rename to languages/golang/encrypt/clientkey.go index ca7d3f683..597911a0c 100644 --- a/languages/golang/stackencrypt/clientkey.go +++ b/languages/golang/encrypt/clientkey.go @@ -1,4 +1,4 @@ -package stackencrypt +package encrypt import "github.com/cipherstash/stack/languages/golang/internal/guest" @@ -6,9 +6,9 @@ import "github.com/cipherstash/stack/languages/golang/internal/guest" // for the process. It is opaque — it prints a redaction under every verb // and hands its bytes to no caller — and it is wiped once consumed. // -// It is the one type both guest packages share: stackauth reads one out of +// It is the one type both guest packages share: auth reads one out of // the developer profile, and this package consumes it. The alias is what -// makes a key read there the type taken here without stackauth importing +// makes a key read there the type taken here without auth importing // this package, so a binary that only wants the profile does not carry the // crypto guest. type ClientKey = guest.ClientKey diff --git a/languages/golang/encrypt/context_field_test.go b/languages/golang/encrypt/context_field_test.go new file mode 100644 index 000000000..7952df4e7 --- /dev/null +++ b/languages/golang/encrypt/context_field_test.go @@ -0,0 +1,236 @@ +package encrypt_test + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/internal/testusers" +) + +// A type whose context is one of its own fields (`context_field`), through +// the deterministic guest: each row is sealed under the context its field +// names, a cipher that names a context refuses a row stored under another +// before any key is retrieved, and a query derives under the named context. + +var notes = []testusers.Note{ + {Tenant: "tenants/acme", Text: "hello", ID: 1}, + {Tenant: "tenants/globex", Text: "hello", ID: 2}, + {Tenant: "tenants/acme", Text: "goodbye", ID: 3}, +} + +func TestContextFieldSealsEachRowUnderItsOwnContext(t *testing.T) { + c := deterministicClient(t) + ctx := context.Background() + cipher := c.DefaultKeyset() + + encrypted, err := testusers.EncryptNote(ctx, cipher, notes) + if err != nil { + t.Fatal(err) + } + for i, e := range encrypted { + if e.Tenant != notes[i].Tenant || e.ID != notes[i].ID { + t.Errorf("row %d: the context field and the passthrough are stored as they are: %+v", i, e) + } + if len(e.Text.Ciphertext) == 0 || len(e.Text.Equality) != 32 { + t.Errorf("row %d: outputs missing: %+v", i, e) + } + } + // The same text under two tenants is two terms: the context came from + // each row, not from the type. + if encrypted[0].Text.Equality.Equal(encrypted[1].Text.Equality) { + t.Error("the same text under two tenants derived one term") + } + + // With no context named, each row opens under the context it stores, + // through the cipher and through the client alike. + for _, d := range []struct { + name string + by encrypt.Decrypter + }{{"cipher", cipher}, {"client", c}} { + back, err := testusers.DecryptNote(ctx, d.by, encrypted) + if err != nil { + t.Fatalf("DecryptNote through the %s: %v", d.name, err) + } + for i := range notes { + if back[i] != notes[i] { + t.Fatalf("through the %s: row %d = %+v, want %+v", d.name, i, back[i], notes[i]) + } + } + } + + // A cipher that names the context refuses a row stored under another, + // before any key is retrieved: the batch holds a globex row. + acme := cipher.Context("tenants/acme") + if _, err := testusers.DecryptNote(ctx, acme, encrypted); !errors.Is(err, encrypt.ErrContextMismatch) { + t.Fatalf("a globex row through the acme cipher: %v, want ErrContextMismatch", err) + } + onlyAcme := []testusers.EncryptedNote{encrypted[0], encrypted[2]} + back, err := testusers.DecryptNote(ctx, acme, onlyAcme) + if err != nil || back[0] != notes[0] || back[1] != notes[2] { + t.Fatalf("acme rows through the acme cipher: %v %+v", err, back) + } + if _, err := testusers.DecryptNote(ctx, cipher.Context("tenants/globex"), onlyAcme); !errors.Is(err, encrypt.ErrContextMismatch) { + t.Fatalf("acme rows through the globex cipher: %v, want ErrContextMismatch", err) + } + + // A stored context changed in storage opens nothing: every field was + // sealed under the original, so the key source refuses it. + moved := encrypted[0] + moved.Tenant = "tenants/globex" + if _, err := testusers.DecryptNote(ctx, cipher, []testusers.EncryptedNote{moved}); !errors.Is(err, encrypt.ErrForbidden) { + t.Fatalf("a row whose stored context was changed: %v, want ErrForbidden", err) + } + + // Encrypt through a cipher that names the context refuses a value + // whose field says otherwise, before anything is sent. + if _, err := testusers.EncryptNote(ctx, acme, notes); !errors.Is(err, encrypt.ErrContextMismatch) { + t.Fatalf("a globex value through the acme cipher: %v, want ErrContextMismatch", err) + } + again, err := testusers.EncryptNote(ctx, acme, notes[:1]) + if err != nil || !again[0].Text.Equality.Equal(encrypted[0].Text.Equality) { + t.Fatalf("an acme value through the acme cipher: %v", err) + } +} + +func TestContextFieldQueriesNameTheContext(t *testing.T) { + c := deterministicClient(t) + ctx := context.Background() + cipher := c.DefaultKeyset() + encrypted, err := testusers.EncryptNote(ctx, cipher, notes) + if err != nil { + t.Fatal(err) + } + // A query through a cipher with no context has nothing to derive under. + if _, err := testusers.NoteFields.Text.Equality(ctx, cipher, "hello"); !errors.Is(err, encrypt.ErrEncoding) || !strings.Contains(err.Error(), "Cipher.Context") { + t.Fatalf("a query with no context: %v, want ErrEncoding naming Cipher.Context", err) + } + // Under the tenant it singles out that tenant's row. + acme := cipher.Context("tenants/acme") + probe, err := testusers.NoteFields.Text.Equality(ctx, acme, "hello") + if err != nil { + t.Fatal(err) + } + if !probe.Equal(encrypted[0].Text.Equality) || probe.Equal(encrypted[1].Text.Equality) || probe.Equal(encrypted[2].Text.Equality) { + t.Error("the probe under tenants/acme does not single out acme's hello") + } + globex, err := testusers.NoteFields.Text.Equality(ctx, cipher.Context("tenants/globex"), "hello") + if err != nil || !globex.Equal(encrypted[1].Text.Equality) { + t.Fatalf("the probe under tenants/globex: %v", err) + } + // A field's own Encrypt seals one value under the named context: the + // column it writes opens in that tenant's row. + one, err := testusers.NoteFields.Text.Encrypt(ctx, acme, "hello") + if err != nil || !one.Equality.Equal(encrypted[0].Text.Equality) { + t.Fatalf("a field's own Encrypt under tenants/acme: %v", err) + } + if _, err := testusers.NoteFields.Text.Encrypt(ctx, cipher, "hello"); !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("a field's own Encrypt with no context: %v, want ErrEncoding", err) + } + updated := encrypted[0] + updated.Text = one + back, err := testusers.DecryptNote(ctx, acme, []testusers.EncryptedNote{updated}) + if err != nil || back[0].Text != "hello" { + t.Fatalf("after a column update: %v %+v", err, back) + } + // A cipher with a context refuses a type whose context is in its tags. + if _, err := testusers.Encrypt(ctx, acme, people[:1]); !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("a context= type through a cipher with a context: %v, want ErrEncoding", err) + } + // A label that is not plain is reported by the first call. + if _, err := testusers.DecryptNote(ctx, cipher.Context("tenants/(acme)"), encrypted[:1]); !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("Context with a label that is not plain: %v, want ErrEncoding", err) + } +} + +// The context-field case of the Rust record fixture: the typed chain and +// the data-plan lowering each sealed a Note under the deterministic source, +// and the generated code opens both under the context they store, refuses +// both under another, and derives the same term. +type contextFieldFixture struct { + KeySource struct { + Seed string `json:"seed"` + } `json:"key_source"` + ContextField struct { + Plaintext struct { + Tenant string `json:"tenant"` + Text string `json:"text"` + ID uint32 `json:"id"` + } `json:"plaintext"` + Records map[string]map[string]map[string]json.RawMessage `json:"records"` + } `json:"context_field"` +} + +func TestGeneratedCodeOpensTheRustContextFieldFixture(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "..", "packages", "stack-encrypt", "tests", "fixtures", "record_lowering.json")) + if err != nil { + t.Fatal(err) + } + var f contextFieldFixture + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + if len(f.ContextField.Records) != 2 { + t.Fatalf("the fixture's context_field case has %d records, want the two authors'", len(f.ContextField.Records)) + } + seedBytes, err := hex.DecodeString(f.KeySource.Seed) + if err != nil || len(seedBytes) != 32 { + t.Fatalf("seed: %v (%d bytes)", err, len(seedBytes)) + } + c, err := encrypt.NewDeterministicClient(context.Background(), [32]byte(seedBytes)) + if errors.Is(err, encrypt.ErrDeterministicGuestNotBuilt) || errors.Is(err, encrypt.ErrGuestNotBuilt) { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + defer c.Close() + ctx := context.Background() + cipher := c.DefaultKeyset() + want := testusers.Note{Tenant: f.ContextField.Plaintext.Tenant, Text: f.ContextField.Plaintext.Text, ID: f.ContextField.Plaintext.ID} + + for author, rec := range f.ContextField.Records { + t.Run(author, func(t *testing.T) { + var tenant string + if err := json.Unmarshal(rec["tenant"]["passthrough"], &tenant); err != nil { + t.Fatal(err) + } + stored := testusers.EncryptedNote{ + Tenant: tenant, + Text: testusers.EncryptedNoteText{ + Ciphertext: hexField(t, rec, "text", "c"), + Equality: hexField(t, rec, "text", "eq"), + }, + ID: want.ID, + } + for name, d := range map[string]encrypt.Decrypter{"cipher": cipher, "client": c, "the acme cipher": cipher.Context(want.Tenant)} { + back, err := testusers.DecryptNote(ctx, d, []testusers.EncryptedNote{stored}) + if err != nil { + t.Fatalf("DecryptNote through %s: %v", name, err) + } + if back[0] != want { + t.Fatalf("DecryptNote through %s = %+v, want %+v", name, back[0], want) + } + } + if _, err := testusers.DecryptNote(ctx, cipher.Context("tenants/globex"), []testusers.EncryptedNote{stored}); !errors.Is(err, encrypt.ErrContextMismatch) { + t.Fatalf("through the globex cipher: %v, want ErrContextMismatch", err) + } + // The term Go derives under the tenant is the bytes Rust stored. + eq, err := testusers.NoteFields.Text.Equality(ctx, cipher.Context(want.Tenant), want.Text) + if err != nil || !eq.Equal(stored.Text.Equality) { + t.Errorf("text equality: %v, equal=%v", err, eq.Equal(stored.Text.Equality)) + } + again, err := testusers.EncryptNote(ctx, cipher, []testusers.Note{want}) + if err != nil || !bytes.Equal(again[0].Text.Equality, stored.Text.Equality) { + t.Errorf("a Go record of the fixture's note derives other terms than Rust did: %v", err) + } + }) + } +} diff --git a/languages/golang/stackencrypt/credentials.go b/languages/golang/encrypt/credentials.go similarity index 83% rename from languages/golang/stackencrypt/credentials.go rename to languages/golang/encrypt/credentials.go index c96dad2c7..50caf3002 100644 --- a/languages/golang/stackencrypt/credentials.go +++ b/languages/golang/encrypt/credentials.go @@ -1,4 +1,4 @@ -package stackencrypt +package encrypt import ( "context" @@ -9,11 +9,11 @@ import ( "os" "sync/atomic" - "github.com/cipherstash/stack/languages/golang/stackauth" + "github.com/cipherstash/stack/languages/golang/auth" ) // Credentials is where a [Client]'s ZeroKMS credentials come from: the -// client id, the client key, and the stackauth strategy that supplies the +// client id, the client key, and the auth strategy that supplies the // bearer token. NewClient resolves them once, host-side — the crypto guest // is never given the environment or a filesystem to look them up itself — // and hands the key to the guest. @@ -22,7 +22,7 @@ import ( // profile, in the Rust client's order. [NewCredentials] takes a client id, // a client key and a strategy explicitly; [OIDCFederation] mints the token // from an identity provider's. Those three are the only implementations: -// the interface is sealed, so a bearer token always comes from a stackauth +// the interface is sealed, so a bearer token always comes from an auth // strategy. A raw token cannot be refreshed when it expires, and a source // outside the strategies would bypass the cross-process refresh lock the // device session shares with the CLI (a refresh token used twice gets the @@ -57,7 +57,7 @@ type resolvedCredentials struct { // ClientKey is the client key. NewClient consumes it whatever the // outcome, as [NewClientKey] describes. ClientKey *ClientKey - // Token supplies the bearer token for every request: a stackauth + // Token supplies the bearer token for every request: an auth // strategy, outside the package's own tests. Token tokenSource // Close, when not nil, releases what the credentials hold open — the @@ -85,11 +85,11 @@ type resolvedCredentials struct { // ErrNoCredentials is [AutoCredentials] finding no token strategy or no // client key in either place it looks. The wrapped error says which, and // what to set. -var ErrNoCredentials = errors.New("stackencrypt: no credentials") +var ErrNoCredentials = errors.New("encrypt: no credentials") // NewCredentials is [Credentials] from explicit values: a client id, a -// client key (from [NewClientKey], or stackauth's typed read), and the -// stackauth strategy that supplies the bearer token (ProfileStore's +// client key (from [NewClientKey], or auth's typed read), and the +// auth strategy that supplies the bearer token (ProfileStore's // AccessKey, DeviceSession, OIDC or Auto). The key is consumed by the first // NewClient given these credentials; a second is refused with // [ErrCredentialsConsumed], as a key is for one client. A nil strategy is @@ -101,7 +101,7 @@ var ErrNoCredentials = errors.New("stackencrypt: no credentials") // has returned, and are the caller's to close after it — the strategy, then // the store (closing the store closes its strategies too). A token asked of // a closed strategy is an error from the operation that needed it. -func NewCredentials(clientID string, key *ClientKey, strategy *stackauth.Strategy) Credentials { +func NewCredentials(clientID string, key *ClientKey, strategy *auth.Strategy) Credentials { c := &explicitCredentials{clientID: clientID, key: key} // A nil *Strategy stored in the interface would be a non-nil source // that fails on first use; left unset, NewClient refuses it up front. @@ -115,7 +115,7 @@ func NewCredentials(clientID string, key *ClientKey, strategy *stackauth.Strateg // the first consumed its key — whether it made a client or refused its // config — so there is nothing left to give. Build new credentials, with a // new key, for another client. -var ErrCredentialsConsumed = errors.New("stackencrypt: the credentials' client key was already consumed by an earlier NewClient") +var ErrCredentialsConsumed = errors.New("encrypt: the credentials' client key was already consumed by an earlier NewClient") // explicitCredentials is NewCredentials. token is the strategy; only this // package's tests put anything else in it. @@ -136,7 +136,7 @@ func (c *explicitCredentials) resolve(context.Context, resolveOptions) (*resolve // A nil token never gets here: NewClient refuses it host-side, before // the guest is read. resolved := &resolvedCredentials{ClientID: c.clientID, ClientKey: c.key, Token: c.token} - if strategy, ok := c.token.(*stackauth.Strategy); ok { + if strategy, ok := c.token.(*auth.Strategy); ok { // The strategy's store is the guest the token lives in and, when // the key was read through it, the key passed through: reported // live, as AutoCredentials reports its profile's. @@ -149,11 +149,11 @@ func (c *explicitCredentials) resolve(context.Context, resolveOptions) (*resolve // String names the credentials' kind and client id; the key prints a // redaction under every verb anyway, but nothing here asks it to. func (c *explicitCredentials) String() string { - return fmt.Sprintf("stackencrypt.NewCredentials{client_id: %s}", c.clientID) + return fmt.Sprintf("encrypt.NewCredentials{client_id: %s}", c.clientID) } // The environment variables AutoCredentials and NewClient read. The profile -// directory's own, CS_CONFIG_PATH, is stackauth's; so is CS_CTS_HOST, which +// directory's own, CS_CONFIG_PATH, is auth's; so is CS_CTS_HOST, which // its strategies take as the authentication endpoint. const ( envClientID = "CS_CLIENT_ID" @@ -189,7 +189,7 @@ var envZeroKMSHost = []string{"CS_ZEROKMS_HOST", "CS_VITUR_HOST"} // directory that does not exist, one that cannot be read, a path that is // not a directory. // -// The profile and the token strategies run in stackauth's credential guest, +// The profile and the token strategies run in auth's credential guest, // not in the crypto guest, which still sees no environment and no // filesystem. The credential guest lives as long as the client, and // Client.Close releases it. @@ -198,24 +198,24 @@ func AutoCredentials() Credentials { return autoCredentials{} } type autoCredentials struct{} // String names the credentials' kind; nothing is resolved to print it. -func (autoCredentials) String() string { return "stackencrypt.AutoCredentials" } +func (autoCredentials) String() string { return "encrypt.AutoCredentials" } func (autoCredentials) resolve(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { - return resolveWithStrategy(ctx, opts, func(ctx context.Context, profile *stackauth.ProfileStore, noProfile error) (*stackauth.Strategy, error) { + return resolveWithStrategy(ctx, opts, func(ctx context.Context, profile *auth.ProfileStore, noProfile error) (*auth.Strategy, error) { strategy, err := profile.Auto(ctx) switch { - case errors.Is(err, stackauth.ErrNotAuthenticated): + case errors.Is(err, auth.ErrNotAuthenticated): if noProfile != nil { err = fmt.Errorf("%w: %w", err, noProfile) } return nil, fmt.Errorf("%w: no token: set %s and %s, or run `stash auth login`: %w", ErrNoCredentials, envAccessKey, envWorkspaceCRN, err) - case errors.Is(err, stackauth.ErrAuthConfig) && accessKeyConfigured(): + case errors.Is(err, auth.ErrConfig) && accessKeyConfigured(): // The status covers every configuration fault the guest reports; // name the variables only when they are what was configured. - return nil, fmt.Errorf("stackencrypt: credentials: check %s and %s: %w", envAccessKey, envWorkspaceCRN, err) + return nil, fmt.Errorf("encrypt: credentials: check %s and %s: %w", envAccessKey, envWorkspaceCRN, err) case err != nil: - return nil, fmt.Errorf("stackencrypt: credentials: %w", err) + return nil, fmt.Errorf("encrypt: credentials: %w", err) } return strategy, nil }) @@ -228,37 +228,37 @@ func (autoCredentials) resolve(ctx context.Context, opts resolveOptions) (*resol // token of the user the call is for, and each distinct IdP token is // exchanged once while its CipherStash token lasts, so one client serves // many users without one user ever riding another's token; -// stackauth.OAuth2TokenSource adapts a golang.org/x/oauth2 source. The +// auth.OAuth2TokenSource adapts a golang.org/x/oauth2 source. The // client key is resolved as // [AutoCredentials] resolves it: CS_CLIENT_ID and CS_CLIENT_KEY, else the // developer profile. // // opts configure the federation strategy as they would -// stackauth.ProfileStore.OIDC: stackauth.WithAuthBaseURL pins the CTS +// auth.ProfileStore.OIDC: auth.WithBaseURL pins the CTS // endpoint for these credentials alone (without it, CS_CTS_HOST overrides -// the endpoint, else it is discovered), and stackauth.WithCacheCapacity +// the endpoint, else it is discovered), and auth.WithCacheCapacity // sets how many users' tokens are kept (1024 unless set). -func OIDCFederation(crn string, provider stackauth.OIDCProvider, opts ...stackauth.StrategyOption) Credentials { +func OIDCFederation(crn string, provider auth.OIDCProvider, opts ...auth.StrategyOption) Credentials { return &oidcCredentials{crn: crn, provider: provider, opts: opts} } type oidcCredentials struct { crn string - provider stackauth.OIDCProvider - opts []stackauth.StrategyOption + provider auth.OIDCProvider + opts []auth.StrategyOption } // String names the credentials' kind and workspace; the provider is not // asked for anything to print it. func (c oidcCredentials) String() string { - return fmt.Sprintf("stackencrypt.OIDCFederation{crn: %s}", c.crn) + return fmt.Sprintf("encrypt.OIDCFederation{crn: %s}", c.crn) } func (c oidcCredentials) resolve(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { - return resolveWithStrategy(ctx, opts, func(ctx context.Context, profile *stackauth.ProfileStore, _ error) (*stackauth.Strategy, error) { + return resolveWithStrategy(ctx, opts, func(ctx context.Context, profile *auth.ProfileStore, _ error) (*auth.Strategy, error) { strategy, err := profile.OIDC(ctx, c.crn, c.provider, c.opts...) if err != nil { - return nil, fmt.Errorf("stackencrypt: credentials: OIDC federation: %w", err) + return nil, fmt.Errorf("encrypt: credentials: OIDC federation: %w", err) } return strategy, nil }) @@ -273,32 +273,32 @@ func (c oidcCredentials) resolve(ctx context.Context, opts resolveOptions) (*res func resolveWithStrategy( ctx context.Context, opts resolveOptions, - strategy func(ctx context.Context, profile *stackauth.ProfileStore, noProfile error) (*stackauth.Strategy, error), + strategy func(ctx context.Context, profile *auth.ProfileStore, noProfile error) (*auth.Strategy, error), ) (_ *resolvedCredentials, err error) { - authOpts := []stackauth.Option{stackauth.WithRoundTripper(opts.Transport)} + authOpts := []auth.Option{auth.WithRoundTripper(opts.Transport)} if opts.RequireLockedMemory { - authOpts = append(authOpts, stackauth.RequireLockedMemory()) + authOpts = append(authOpts, auth.RequireLockedMemory()) } - profile, err := stackauth.Resolve(ctx, authOpts...) + profile, err := auth.Resolve(ctx, authOpts...) // noProfile is why there is no profile to consult, when there is none: // kept for the errors that would have consulted it, so a profile that // exists but cannot be opened is not reported as "not logged in". var noProfile error - if errors.Is(err, stackauth.ErrNoProfile) { + if errors.Is(err, auth.ErrNoProfile) { // The strategies that need no profile still run, in a guest with // nothing mounted; every profile read on it is ErrNoProfile. noProfile = err - profile, err = stackauth.OpenWithoutProfile(ctx, authOpts...) + profile, err = auth.OpenWithoutProfile(ctx, authOpts...) } if errors.Is(err, ErrMemoryLock) { // Under RequireLockedMemory the guest is refused before anything is // resolved — on a host that cannot lock or reserve its memory at all // (a 32-bit address space, a zero RLIMIT_MEMLOCK). Name which guest, // as the client's own report does. - return nil, fmt.Errorf("stackencrypt: credentials: the credential guest: %w", err) + return nil, fmt.Errorf("encrypt: credentials: the credential guest: %w", err) } if err != nil { - return nil, fmt.Errorf("stackencrypt: credentials: %w", err) + return nil, fmt.Errorf("encrypt: credentials: %w", err) } defer func() { if err != nil { @@ -377,27 +377,27 @@ func clientKeyFromEnv() (string, *ClientKey, error) { // noProfile, when not nil, is why the profile could not be opened; the // store's own answer to a read is then a bare ErrNoProfile, and the reason // is the useful one. -func clientKeyFromProfile(ctx context.Context, profile *stackauth.ProfileStore, noProfile error) (string, *ClientKey, error) { +func clientKeyFromProfile(ctx context.Context, profile *auth.ProfileStore, noProfile error) (string, *ClientKey, error) { notConfigured := func(err error) error { return fmt.Errorf("%w: no client key: set %s and %s, or run `stash auth login`: %w", ErrNoCredentials, envClientID, envClientKey, err) } workspace, err := profile.CurrentWorkspaceStore(ctx) - if errors.Is(err, stackauth.ErrNoProfile) && noProfile != nil { + if errors.Is(err, auth.ErrNoProfile) && noProfile != nil { err = noProfile } - if errors.Is(err, stackauth.ErrNoProfile) || errors.Is(err, stackauth.ErrNoCurrentWorkspace) { + if errors.Is(err, auth.ErrNoProfile) || errors.Is(err, auth.ErrNoCurrentWorkspace) { return "", nil, notConfigured(err) } if err != nil { - return "", nil, fmt.Errorf("stackencrypt: credentials: %w", err) + return "", nil, fmt.Errorf("encrypt: credentials: %w", err) } clientID, key, err := workspace.SecretKey(ctx) - if errors.Is(err, stackauth.ErrNotFound) { + if errors.Is(err, auth.ErrNotFound) { return "", nil, notConfigured(err) } if err != nil { - return "", nil, fmt.Errorf("stackencrypt: credentials: reading the client key: %w", err) + return "", nil, fmt.Errorf("encrypt: credentials: reading the client key: %w", err) } return clientID, key, nil } diff --git a/languages/golang/stackencrypt/credentials_test.go b/languages/golang/encrypt/credentials_test.go similarity index 94% rename from languages/golang/stackencrypt/credentials_test.go rename to languages/golang/encrypt/credentials_test.go index b04911e64..fc14a75df 100644 --- a/languages/golang/stackencrypt/credentials_test.go +++ b/languages/golang/encrypt/credentials_test.go @@ -1,4 +1,4 @@ -package stackencrypt +package encrypt import ( "context" @@ -15,8 +15,8 @@ import ( "testing" "time" + "github.com/cipherstash/stack/languages/golang/auth" "github.com/cipherstash/stack/languages/golang/internal/guest" - "github.com/cipherstash/stack/languages/golang/stackauth" ) // Credential resolution, pinned against the Rust client's order. The @@ -45,12 +45,12 @@ const ( profileClientID = "0b1e8a44-5c1d-4d4e-9b52-3f0e6c2f8a17" ) -// authGuestOrSkip skips where stackauth's credential guest is not built, +// authGuestOrSkip skips where auth's credential guest is not built, // as guestOrSkip does for this package's own. func authGuestOrSkip(t *testing.T) { t.Helper() - store, err := stackauth.OpenWithoutProfile(context.Background()) - if errors.Is(err, stackauth.ErrGuestNotBuilt) { + store, err := auth.OpenWithoutProfile(context.Background()) + if errors.Is(err, auth.ErrGuestNotBuilt) { t.Skip(err) } if err != nil { @@ -114,7 +114,7 @@ func newProfile(t *testing.T, files profileFiles) string { } } } - store, err := stackauth.Open(context.Background(), dir) + store, err := auth.Open(context.Background(), dir) if err != nil { t.Fatal(err) } @@ -208,7 +208,7 @@ func TestAutoCredentialsFromTheEnvironmentWithNoProfile(t *testing.T) { authGuestOrSkip(t) // The CI shape: four variables and no profile directory at all. cleanEnv(t, filepath.Join(t.TempDir(), "absent")) - auth := newAuthServer(t) + cts := newAuthServer(t) t.Setenv(envAccessKey, testAccessKey) t.Setenv(envWorkspaceCRN, testCRN) t.Setenv(envClientID, testClientID) @@ -220,8 +220,8 @@ func TestAutoCredentialsFromTheEnvironmentWithNoProfile(t *testing.T) { if resolved.ClientID != testClientID || string(guest.KeyBytes(resolved.ClientKey)) != testClientKey { t.Error("the client key is not the environment's") } - if got := token(t, resolved); got != auth.jwt || auth.calls.Load() != 1 { - t.Errorf("Token = %q after %d exchanges, want the access key's", got, auth.calls.Load()) + if got := token(t, resolved); got != cts.jwt || cts.calls.Load() != 1 { + t.Errorf("Token = %q after %d exchanges, want the access key's", got, cts.calls.Load()) } } @@ -262,14 +262,14 @@ func TestAutoCredentialsPrecedence(t *testing.T) { }) t.Run("the environment's access key wins over the stored session", func(t *testing.T) { cleanEnv(t, newProfile(t, loggedIn("profile-token"))) - auth := newAuthServer(t) + cts := newAuthServer(t) t.Setenv(envAccessKey, testAccessKey) t.Setenv(envWorkspaceCRN, testCRN) resolved, err := resolve(t) if err != nil { t.Fatal(err) } - if got := token(t, resolved); got != auth.jwt { + if got := token(t, resolved); got != cts.jwt { t.Errorf("Token = %q, want the access key's", got) } // The key still comes from the profile. @@ -291,13 +291,13 @@ func TestAutoCredentialsMissing(t *testing.T) { }{ { name: "nothing anywhere", - want: []error{ErrNoCredentials, stackauth.ErrNotAuthenticated}, + want: []error{ErrNoCredentials, auth.ErrNotAuthenticated}, names: envAccessKey, }, { name: "a profile with no login", profile: &profileFiles{}, - want: []error{ErrNoCredentials, stackauth.ErrNotAuthenticated}, + want: []error{ErrNoCredentials, auth.ErrNotAuthenticated}, names: "stash auth login", }, { @@ -311,13 +311,13 @@ func TestAutoCredentialsMissing(t *testing.T) { { name: "a stored session and no secretkey.json", profile: &profileFiles{auth: loggedIn("t").auth}, - want: []error{ErrNoCredentials, stackauth.ErrNotFound}, + want: []error{ErrNoCredentials, auth.ErrNotFound}, names: envClientKey, }, { name: "an access key with no workspace CRN", env: map[string]string{envAccessKey: testAccessKey, envClientID: testClientID, envClientKey: testClientKey}, - want: []error{stackauth.ErrAuthConfig}, + want: []error{auth.ErrConfig}, }, } { t.Run(tc.name, func(t *testing.T) { @@ -355,7 +355,7 @@ func TestAutoCredentialsNamesWhyTheProfileCouldNotBeOpened(t *testing.T) { t.Run("the token", func(t *testing.T) { cleanEnv(t, file) _, err := resolve(t) - for _, want := range []error{ErrNoCredentials, stackauth.ErrNoProfile} { + for _, want := range []error{ErrNoCredentials, auth.ErrNoProfile} { if !errors.Is(err, want) { t.Errorf("error %v, want %v", err, want) } @@ -369,7 +369,7 @@ func TestAutoCredentialsNamesWhyTheProfileCouldNotBeOpened(t *testing.T) { t.Setenv(envAccessKey, testAccessKey) t.Setenv(envWorkspaceCRN, testCRN) _, err := resolve(t) - for _, want := range []error{ErrNoCredentials, stackauth.ErrNoProfile} { + for _, want := range []error{ErrNoCredentials, auth.ErrNoProfile} { if !errors.Is(err, want) { t.Errorf("error %v, want %v", err, want) } @@ -427,7 +427,7 @@ func TestNewClientWithAutoCredentials(t *testing.T) { t.Fatalf("the error carries key material: %q", err) } // A failed NewClient released the credentials it resolved. - if _, err := released.Token.Token(context.Background()); !errors.Is(err, stackauth.ErrState) { + if _, err := released.Token.Token(context.Background()); !errors.Is(err, auth.ErrState) { t.Fatalf("the token source after a failed NewClient: %v, want ErrState", err) } } @@ -676,7 +676,7 @@ func TestAutoCredentialsCloseReleasesTheGuest(t *testing.T) { if err := resolved.Close(); err != nil { t.Fatal(err) } - if _, err := resolved.Token.Token(context.Background()); !errors.Is(err, stackauth.ErrState) { + if _, err := resolved.Token.Token(context.Background()); !errors.Is(err, auth.ErrState) { t.Fatalf("Token after Close: %v, want ErrState", err) } } @@ -684,7 +684,7 @@ func TestAutoCredentialsCloseReleasesTheGuest(t *testing.T) { func TestCredentialsPrintNoKey(t *testing.T) { for _, c := range []Credentials{AutoCredentials(), newTestCredentials(testClientID, NewClientKey([]byte(testClientKey)), staticToken("t"))} { for _, verb := range []string{"%v", "%+v", "%s"} { - if out := fmt.Sprintf(verb, c); strings.Contains(out, testClientKey[:16]) || !strings.HasPrefix(out, "stackencrypt.") { + if out := fmt.Sprintf(verb, c); strings.Contains(out, testClientKey[:16]) || !strings.HasPrefix(out, "encrypt.") { t.Errorf("%s: %q", verb, out) } } @@ -699,7 +699,7 @@ func (f credentialsFunc) resolve(ctx context.Context, opts resolveOptions) (*res func ptr(s string) *string { return &s } -// NewCredentials takes its token only from a stackauth strategy: a nil one +// NewCredentials takes its token only from an auth strategy: a nil one // is refused host-side, before any guest is read, and the key is consumed // all the same — the credentials are spent, as on any refused config. func TestNewCredentialsRefusesANilStrategy(t *testing.T) { @@ -723,13 +723,13 @@ func TestNewCredentialsRefusesANilStrategy(t *testing.T) { func TestNewCredentialsReportsTheStrategysMemoryLock(t *testing.T) { authGuestOrSkip(t) ctx := context.Background() - // Best effort, stackauth's default: the store opens whatever the lock. - store, err := stackauth.OpenWithoutProfile(ctx) + // Best effort, auth's default: the store opens whatever the lock. + store, err := auth.OpenWithoutProfile(ctx) if err != nil { t.Fatal(err) } defer store.Close() - strategy, err := store.AccessKey(ctx, testCRN, testAccessKey, stackauth.WithAuthBaseURL("https://cts.example.com")) + strategy, err := store.AccessKey(ctx, testCRN, testAccessKey, auth.WithBaseURL("https://cts.example.com")) if err != nil { t.Fatal(err) } @@ -761,15 +761,15 @@ func TestRequireLockedMemoryRefusesUnlockedCredentials(t *testing.T) { for name, base := range map[string]func(t *testing.T) Credentials{ "NewCredentials": func(t *testing.T) Credentials { cleanEnv(t, t.TempDir()) - auth := newAuthServer(t) + cts := newAuthServer(t) ctx := context.Background() - // Best effort, stackauth's default. - store, err := stackauth.OpenWithoutProfile(ctx) + // Best effort, auth's default. + store, err := auth.OpenWithoutProfile(ctx) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) - strategy, err := store.AccessKey(ctx, testCRN, testAccessKey, stackauth.WithAuthBaseURL(auth.URL)) + strategy, err := store.AccessKey(ctx, testCRN, testAccessKey, auth.WithBaseURL(cts.URL)) if err != nil { t.Fatal(err) } @@ -801,7 +801,7 @@ func TestRequireLockedMemoryRefusesUnlockedCredentials(t *testing.T) { } creds, resolved := forcedCreds(t) // No crypto guest is needed: the refusal precedes it. - _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL), WithGuest(wasiProbe), WithRequireLockedMemory()) + _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL), withGuest(wasiProbe), WithRequireLockedMemory()) if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "credential guest") { t.Fatalf("NewClient under WithRequireLockedMemory: %v, want ErrMemoryLock naming the credential guest", err) } @@ -820,7 +820,7 @@ func TestRequireLockedMemoryRefusesUnlockedCredentials(t *testing.T) { t.Error("the key still holds material after the credentials were refused") } if (*resolved).Close != nil { - if _, err := (*resolved).Token.Token(context.Background()); !errors.Is(err, stackauth.ErrState) { + if _, err := (*resolved).Token.Token(context.Background()); !errors.Is(err, auth.ErrState) { t.Errorf("the token source after the refusal: %v, want it released (ErrState)", err) } } @@ -852,7 +852,7 @@ func TestRequireLockedMemoryAcceptsLockedCredentials(t *testing.T) { } return r, nil }) - _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL), WithGuest(wasiProbe), WithRequireLockedMemory()) + _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL), withGuest(wasiProbe), WithRequireLockedMemory()) if asked.Load() == 0 { t.Fatal("the credentials' memory report was not asked") } @@ -868,13 +868,13 @@ func TestNewCredentialsLeavesTheStrategyToTheCaller(t *testing.T) { guestOrSkip(t) authGuestOrSkip(t) ctx := context.Background() - store, err := stackauth.OpenWithoutProfile(ctx) + store, err := auth.OpenWithoutProfile(ctx) if err != nil { t.Fatal(err) } defer store.Close() cts := newStub(t, http.StatusUnauthorized, "", "nope") - strategy, err := store.AccessKey(ctx, testCRN, "CSAKtest.key", stackauth.WithAuthBaseURL(cts.URL)) + strategy, err := store.AccessKey(ctx, testCRN, "CSAKtest.key", auth.WithBaseURL(cts.URL)) if err != nil { t.Fatal(err) } @@ -896,7 +896,7 @@ func TestNewCredentialsLeavesTheStrategyToTheCaller(t *testing.T) { // Still open: asked again, it goes back to CTS rather than failing // with ErrState. before := len(cts.requests) - if _, err := strategy.Token(ctx); errors.Is(err, stackauth.ErrState) { + if _, err := strategy.Token(ctx); errors.Is(err, auth.ErrState) { t.Fatalf("the strategy after a failed NewClient: %v, want it still open", err) } if len(cts.requests) == before { diff --git a/languages/golang/encrypt/doc.go b/languages/golang/encrypt/doc.go new file mode 100644 index 000000000..71d4de4b0 --- /dev/null +++ b/languages/golang/encrypt/doc.go @@ -0,0 +1,188 @@ +// Package encrypt is the Stack Encrypt Go SDK: searchable, field-level +// encryption under per-value ZeroKMS data keys, running the stack-encrypt +// Rust engine unmodified inside a WASI guest under wazero (CGO_ENABLED=0). +// +// # Use the SDK +// +// 1. Add the generator to your module (Go 1.26 or later): +// +// go get -tool github.com/cipherstash/stack/languages/golang/cmd/stashgen +// +// 2. Put a stash tag on every exported field of the struct, and a go:generate +// comment beside it: +// +// //go:generate go tool stashgen -type User +// type User struct { +// _ struct{} `stash:"context=users"` +// ID int64 `stash:"id,passthrough"` +// Email string `stash:"email,encrypt,index=equality;match"` +// Age uint32 `stash:"age,encrypt,index=equality;ore"` +// } +// +// 3. Run the generator. It writes user_stash.go beside the struct: +// +// go generate ./... +// +// 4. Commit the generated file. +// +// 5. Call the generated functions where you write and read: +// +// client, err := encrypt.NewClient(ctx) +// if err != nil { +// return err +// } +// defer client.Close() +// cipher := client.Keyset(encrypt.KeysetName("tenant-42")) +// encrypted, err := users.Encrypt(ctx, cipher, people) // []users.EncryptedUser +// opened, err := users.Decrypt(ctx, cipher, encrypted) // []users.User +// term, err := users.Fields.Email.Equality(ctx, cipher, "bob@example.com") +// +// Encrypt and Decrypt send one ZeroKMS request for each 500 sealed values in +// the batch, plus one the first time a keyset is used. +// +// 6. Store the encrypted type. Each sealed field is one or more columns of +// bytes ([Ciphertext] and the term types), each implementing driver.Valuer +// and sql.Scanner, so a database library binds and scans each one as bytes; +// map each one to its own column. +// +// 7. Run the generator again after each change to the struct or to a tag. A +// change to the fields of the struct stops the build until you do. +// +// 8. In CI, run the generator and fail when a generated file changes: +// +// go generate ./... && git diff --exit-code && test -z "$(git status --porcelain)" +// +// git diff sees only tracked files; the git status check also fails on a +// generated file that was never committed. +// +// The rest is the reference. The generator's own reference — the tag +// grammar, the flags, what it writes and what it refuses — is in +// cmd/stashgen/README.md. +// +// # Shape +// +// A [Client] is one wasm instance and one ZeroKMS client: [NewClient] +// resolves the client's [Credentials], instantiates the embedded guest, hands +// it the client key once — the [ClientKey] the credentials resolved to is +// consumed and wiped, whatever the outcome — and loads the client's default +// keyset. It takes functional options ([ClientOption]), every one with a +// default, so NewClient(ctx) alone is a working client. [Client.Close] runs +// the guest's shutdown so the client key and every loaded index key are +// wiped before the instance is freed. +// +// A [Cipher] is the client bound to one keyset ([Client.Keyset] and +// [Client.DefaultKeyset]) and to any extension of the context +// ([Cipher.Extend]): what changes from one caller to the next — a tenant, a +// region — attaches here and not to each call, so the write, the query and +// the read cannot use different ones. A Cipher opens only its own keyset's +// records; the [Client] opens records from any of its keysets. Both are a +// [Decrypter], which the generated Decrypt functions take. +// +// # Declarations +// +// A struct's stash tags declare how each field is encrypted: the context of +// the struct, and for each field whether it is sealed, which indexes are +// derived beside it, or whether it passes through as it is. stashgen reads +// the tags and writes the encrypted type, Encrypt, Decrypt and Fields into +// the struct's package; the generated code hands the declaration to the +// engine as data through package gensupport, and the engine runs the same +// plan the Rust chain and the derive run. A program never builds or names a +// plan, and no call takes a context or an index choice of its own. +// +// Passthrough fields never cross the binding: the engine does nothing to a +// passthrough value that a program could observe, and the FFI codec cannot +// carry every Go type a program stores beside a ciphertext (a time.Time, a +// driver.Valuer). An opaque struct crosses as one JSON document and is one +// column. +// +// # Terms +// +// A sealed field with an index gets a term beside its ciphertext: +// [EqualityTerm], [MatchTerm], [OreTerm] or [OpeTerm], byte-equal to the ones +// the Rust crate derives, so a term from a generated Fields entry compares +// against a stored term from any language. The indexes are [Equality], +// [Match], [Ore] and [Ope]; [JSON] is declared and refused until the engine +// derives it. Every stored type implements driver.Valuer and sql.Scanner. +// +// A match index needs text with at least one token: the engine derives no +// match term for an empty string, separator-only text, or text shorter than +// the n-gram length (3 characters), because an empty term would match every +// row. Encrypt then fails for the whole batch with [ErrTerm], naming the row, +// the field and the index, so an optional or short value does not belong +// under match. +// +// # Transport and auth +// +// The guest imports exactly two host functions: an HTTP send, served by any +// [net/http.RoundTripper], and a bearer-token fetch, served by the +// credentials' auth strategy. What crosses per ZeroKMS call is what would +// cross TLS anyway; derived key material never leaves the guest. Under +// [AutoCredentials] and [OIDCFederation] the same RoundTripper also carries +// the authentication requests to CTS, so one scoped to the ZeroKMS host alone +// is not enough. Under [NewCredentials] those requests go through the store +// the caller opened the strategy from. +// +// # Credentials +// +// A [Credentials] supplies the client id, the client key and the auth +// strategy the token comes from, and NewClient resolves it host-side: the +// crypto guest is never given the environment or a filesystem to find them +// in. The default, [AutoCredentials], mirrors the Rust client — the +// environment first (CS_CLIENT_ACCESS_KEY with CS_WORKSPACE_CRN for the +// token, CS_CLIENT_ID with CS_CLIENT_KEY for the key), then the developer +// profile, which it reads through the auth package's credential guest. +// CS_ZEROKMS_HOST (or CS_VITUR_HOST) pins the endpoint whatever the +// credentials. [NewCredentials] takes a client id, a client key and a +// strategy explicitly, and [OIDCFederation] mints the token from an identity +// provider's. None takes a raw token: a token is always a strategy's, since +// a raw one cannot be refreshed when it expires. Credentials that cannot be +// resolved fail NewClient with [ErrNoCredentials]; credentials that resolve +// but do not work fail it too, at the one ZeroKMS round trip it makes. +// +// # Errors +// +// 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: [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. +// No error, warning or log line holds a plaintext value; a generated type +// hides its sealed fields when a program prints it. +// +// # Host runtime +// +// The guest also imports WASI random_get and clock_time_get, and the +// cipher's security rests on the first: ZeroKMS IVs and AEAD nonces are +// drawn from it. wazero's defaults for both are deterministic, so every +// instance is configured with the process CSPRNG ([crypto/rand.Reader]) and +// the system clocks. +// +// # Memory +// +// Every key the guest holds — the client key, each loaded index key, each +// data key for the length of a call — lives in the guest's linear memory, +// and the package supplies that memory itself: reserved once so growth never +// copies it, locked in RAM (mlock, VirtualLock) so it is never written to +// swap, excluded from core dumps on Linux (MADV_DONTDUMP), and wiped before +// it is released, on every release path. That is done at allocation, where +// the caller cannot get it wrong, and not at exit, where they cannot be +// relied on. +// +// The lock is best effort: RLIMIT_MEMLOCK defaults to 64 KiB on many Linux +// hosts and the guest is larger, so it is commonly refused, and a client +// then works on with memory that may be swapped. [Client.MemoryLocked] +// reports the outcome and [Client.MemoryLockError] the reason, naming the +// limit to raise. [WithRequireLockedMemory] turns a refusal into a +// [NewClient] failure with [ErrMemoryLock], and refuses any later growth of +// the guest's memory that cannot be locked. A Client prints its memory state +// ([Client.String]) and logs it ([Client.LogValue]). +// +// Between calls the guest holds the client key and its keyset cache. Data +// keys are per-call values, wiped when the export returns, and every buffer +// staged for a call is wiped before the call's result is returned. One +// exception remains: after Decrypt, one unwiped copy of each opened string +// stays in the guest's freed heap until that memory is reused. The test +// TestPlaintextDoesNotRemainInGuestMemoryAfterDecrypt records the gap and is +// skipped until vitaminc wipes that copy. +package encrypt diff --git a/languages/golang/stackencrypt/errors.go b/languages/golang/encrypt/errors.go similarity index 88% rename from languages/golang/stackencrypt/errors.go rename to languages/golang/encrypt/errors.go index 212c6bb2f..1445c2d1d 100644 --- a/languages/golang/stackencrypt/errors.go +++ b/languages/golang/encrypt/errors.go @@ -1,4 +1,4 @@ -package stackencrypt +package encrypt import "github.com/cipherstash/stack/languages/golang/internal/guest" @@ -9,7 +9,7 @@ import "github.com/cipherstash/stack/languages/golang/internal/guest" // // They are the sentinels every guest package shares (one status table for // every guest, decoded once), exposed here under this package's names: an -// error from stackauth is the same value, so errors.Is holds across the +// error from auth is the same value, so errors.Is holds across the // two. var ( // ErrAuthentication is an AEAD open failure: a tampered ciphertext, a @@ -49,6 +49,11 @@ var ( // under another keyset, before any key is retrieved. Open it through the // Client, which is not bound to one keyset. ErrForeignKeyset = guest.ErrForeignKeyset + // ErrContextMismatch is a row whose context field, stored in the clear + // beside its sealed fields, is not the context named with + // [Cipher.Context]. Refused before any key is retrieved; a row stored + // under another tenant is a mismatch, never a decrypted value. + ErrContextMismatch = guest.ErrContextMismatch // ErrMemoryLock is guest memory that could not be locked in RAM (or, // on Linux, excluded from core dumps). NewClient returns it when // WithRequireLockedMemory is given, and so does any later call under diff --git a/languages/golang/encrypt/example/README.md b/languages/golang/encrypt/example/README.md new file mode 100644 index 000000000..0a995caca --- /dev/null +++ b/languages/golang/encrypt/example/README.md @@ -0,0 +1,32 @@ +# encrypt example + +A runnable tour of the Go SDK against real ZeroKMS: declare a struct with +`stash` tags, generate its encrypted type, seal a batch in one request, derive +a query term, and open the batch again. + +## Running it + +```bash +stash auth login # once; the example reads ~/.cipherstash +mise run go:encrypt:example # builds both guests, then runs +``` + +Or, if you would rather drive it yourself: + +```bash +mise run wasm:guest:build wasm:auth-guest:build +cd languages/golang && go run ./encrypt/example +``` + +The guest builds are not optional. The `encrypt` package embeds +`wasm/stack_encrypt_guest.wasm` and `auth` embeds `wasm/stack_auth_guest.wasm`; +both are gitignored, so a fresh checkout has no guests and `NewClient` fails +until they are built. + +## What it shows + +- `model.go`: the struct and its `go:generate` line. +- `user_stash.go`: what `stashgen` wrote from the tags. Regenerate it with + `go generate ./...`; CI fails when that changes a committed file. +- `main.go`: `Encrypt`, `Fields.Email.Equality` and `Decrypt`, the only three + calls a program makes. diff --git a/languages/golang/encrypt/example/main.go b/languages/golang/encrypt/example/main.go new file mode 100644 index 000000000..2157699d7 --- /dev/null +++ b/languages/golang/encrypt/example/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/cipherstash/stack/languages/golang/encrypt" +) + +func main() { + if err := run(context.Background()); err != nil { + fmt.Fprintf(os.Stderr, "\nerror: %v\n", err) + os.Exit(1) + } +} + +func run(ctx context.Context) error { + // No options: the credentials come from AutoCredentials, which is the + // environment first (CS_CLIENT_ACCESS_KEY + CS_WORKSPACE_CRN, CS_CLIENT_ID + // + CS_CLIENT_KEY), then the developer profile `stash auth login` writes. + client, err := encrypt.NewClient(ctx) + if err != nil { + return fmt.Errorf("connecting to ZeroKMS: %w", err) + } + defer client.Close() + fmt.Printf("connected (%v)\n", client) + + // One cipher for one tenant: the default keyset, and the tenant as a + // part of every field's context. Every call through it carries both. + cipher := client.DefaultKeyset().Extend("tenant-42") + + people := []User{ + {ID: 1, Email: "alice@example.com", Age: 34}, + {ID: 2, Email: "bob@example.com", Age: 29}, + } + + // One call, one ZeroKMS request for both rows. + encrypted, err := Encrypt(ctx, cipher, people) + if err != nil { + return fmt.Errorf("encrypt: %w", err) + } + for _, e := range encrypted { + // EncryptedUser prints its passthrough fields and hides the rest. + fmt.Printf("stored: %v (ciphertext %d bytes, equality %d bytes, match %d positions)\n", + e, len(e.Email.Ciphertext), len(e.Email.Equality), len(e.Email.Match)/2) + } + + // A query term for one field compares against the stored terms. + term, err := Fields.Email.Equality(ctx, cipher, "bob@example.com") + if err != nil { + return fmt.Errorf("query term: %w", err) + } + for _, e := range encrypted { + fmt.Printf("id %d matches bob@example.com: %v\n", e.ID, term.Equal(e.Email.Equality)) + } + + // Decrypt through the cipher. It carries the extension that Encrypt + // used, and it refuses rows from another keyset. The client would refuse + // these rows: it opens only rows sealed with no extension. + back, err := Decrypt(ctx, cipher, encrypted) + if err != nil { + return fmt.Errorf("decrypt: %w", err) + } + // Print ids only: everything else here is plaintext. + for _, u := range back { + fmt.Printf("decrypted id %d\n", u.ID) + } + return nil +} diff --git a/languages/golang/encrypt/example/model.go b/languages/golang/encrypt/example/model.go new file mode 100644 index 000000000..2982acdba --- /dev/null +++ b/languages/golang/encrypt/example/model.go @@ -0,0 +1,22 @@ +// Command example encrypts a batch of users against real ZeroKMS, using the +// credentials `stash auth login` leaves in the developer profile (or the +// CS_* environment variables, which win), and reads them back. +// +// stash auth login +// mise run go:encrypt:example # builds both guests, then runs +// +// The struct's stash tags are the declaration; user_stash.go beside this +// file is what `go generate` wrote from them, and the program calls only +// the functions in it. +package main + +//go:generate go run github.com/cipherstash/stack/languages/golang/cmd/stashgen -type User + +// User is one row. Every exported field carries a stash tag, so a new field +// cannot reach the database unencrypted by accident. +type User struct { + _ struct{} `stash:"context=users"` + ID int64 `stash:"id,passthrough"` + Email string `stash:"email,encrypt,index=equality;match"` + Age uint32 `stash:"age,encrypt,index=equality;ore"` +} diff --git a/languages/golang/encrypt/example/user_stash.go b/languages/golang/encrypt/example/user_stash.go new file mode 100644 index 000000000..13cf2b6ef --- /dev/null +++ b/languages/golang/encrypt/example/user_stash.go @@ -0,0 +1,171 @@ +// Code generated by stashgen. DO NOT EDIT. + +package main + +import ( + "context" + "log/slog" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/gensupport" +) + +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion1 + +// User prints its sealed fields in the clear: it has no String or LogValue +// method. Write them, or run stashgen with -redact. + +type EncryptedUser struct { + ID int64 + Email EncryptedUserEmail + Age EncryptedUserAge +} + +type EncryptedUserEmail struct { + Ciphertext encrypt.Ciphertext + Equality encrypt.EqualityTerm + Match encrypt.MatchTerm +} + +type EncryptedUserAge struct { + Ciphertext encrypt.Ciphertext + Equality encrypt.EqualityTerm + Ore encrypt.OreTerm +} + +func (e EncryptedUser) String() string { + return gensupport.Redacted("EncryptedUser", map[string]any{"ID": e.ID}, "Email", "Age") +} + +func (e EncryptedUser) LogValue() slog.Value { + return gensupport.RedactedLog(map[string]any{"ID": e.ID}, "Email", "Age") +} + +// Stops compiling when User gains, loses, reorders or retypes a field. +var _ = userShape(User{}) + +type userShape struct { + _ struct{} + ID int64 + Email string + Age uint32 +} + +var declaration = gensupport.Declare("users"). + Passthrough("id"). + EncryptIndex("email", gensupport.String, encrypt.Equality, encrypt.Match()). + EncryptIndex("age", gensupport.Uint32, encrypt.Equality, encrypt.Ore) + +var codec = gensupport.New(gensupport.Generated[User, EncryptedUser]{ + TypeName: "User", + Declaration: declaration, + PrintsPlaintext: true, + Source: func(v User) gensupport.Values { + return gensupport.Values{ + "id": v.ID, + "email": v.Email, + "age": v.Age, + } + }, + Seal: func(rec gensupport.Record) (EncryptedUser, error) { + var e EncryptedUser + var err error + if e.ID, err = gensupport.Passthrough[int64](rec, "id"); err != nil { + return EncryptedUser{}, err + } + e.Email = EncryptedUserEmail{ + Ciphertext: rec["email"].Ciphertext, + Equality: rec["email"].Equality, + Match: rec["email"].Match, + } + e.Age = EncryptedUserAge{ + Ciphertext: rec["age"].Ciphertext, + Equality: rec["age"].Equality, + Ore: rec["age"].Ore, + } + return e, nil + }, + Open: func(e EncryptedUser) gensupport.Record { + return gensupport.Record{ + "id": {Value: e.ID}, + "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, + "age": {Ciphertext: e.Age.Ciphertext, Equality: e.Age.Equality, Ore: e.Age.Ore}, + } + }, + Value: func(e EncryptedUser, vals gensupport.Values) (User, error) { + var v User + var err error + if v.ID, err = gensupport.Get[int64](vals, "id"); err != nil { + return User{}, err + } + if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { + return User{}, err + } + if v.Age, err = gensupport.Get[uint32](vals, "age"); err != nil { + return User{}, err + } + return v, nil + }, +}) + +// Encrypt seals each User, with one ZeroKMS request for each 500 sealed values, +// plus one the first time a keyset is used. The result has one element for each +// input, in the same order. +func Encrypt(ctx context.Context, cipher *encrypt.Cipher, main []User) ([]EncryptedUser, error) { + return codec.Encrypt(ctx, cipher, main) +} + +// Decrypt opens each EncryptedUser, with one ZeroKMS request for each 500 +// sealed values, plus one the first time a keyset is used. +func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedUser) ([]User, error) { + return codec.Decrypt(ctx, d, encrypted) +} + +var Fields = struct { + Email EmailField + Age AgeField +}{ + Email: EmailField{gensupport.NewField[string](declaration, "email")}, + Age: AgeField{gensupport.NewField[uint32](declaration, "age")}, +} + +type EmailField struct { + field gensupport.Field[string] +} + +func (f EmailField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (EncryptedUserEmail, error) { + out, err := f.field.Encrypt(ctx, c, v) + if err != nil { + return EncryptedUserEmail{}, err + } + return EncryptedUserEmail{Ciphertext: out.Ciphertext, Equality: out.Equality, Match: out.Match}, nil +} + +func (f EmailField) Equality(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.EqualityTerm, error) { + return f.field.Equality(ctx, c, v) +} + +func (f EmailField) Match(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.MatchTerm, error) { + return f.field.Match(ctx, c, v) +} + +type AgeField struct { + field gensupport.Field[uint32] +} + +func (f AgeField) Encrypt(ctx context.Context, c *encrypt.Cipher, v uint32) (EncryptedUserAge, error) { + out, err := f.field.Encrypt(ctx, c, v) + if err != nil { + return EncryptedUserAge{}, err + } + return EncryptedUserAge{Ciphertext: out.Ciphertext, Equality: out.Equality, Ore: out.Ore}, nil +} + +func (f AgeField) Equality(ctx context.Context, c *encrypt.Cipher, v uint32) (encrypt.EqualityTerm, error) { + return f.field.Equality(ctx, c, v) +} + +func (f AgeField) Ore(ctx context.Context, c *encrypt.Cipher, v uint32) (encrypt.OreTerm, error) { + return f.field.Ore(ctx, c, v) +} diff --git a/languages/golang/encrypt/export_test.go b/languages/golang/encrypt/export_test.go new file mode 100644 index 000000000..e3322e275 --- /dev/null +++ b/languages/golang/encrypt/export_test.go @@ -0,0 +1,130 @@ +package encrypt + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "testing" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/vitaminc/bindings/go/vcffi" +) + +// The test-only ways to give a client a token. The public API takes tokens +// only from auth strategies; the tests that talk to httptest stubs +// need a fixed one, and get it here rather than through anything a caller +// could reach. + +// tokenFunc adapts a function to a tokenSource. +type tokenFunc func(ctx context.Context) (string, error) + +func (f tokenFunc) Token(ctx context.Context) (string, error) { return f(ctx) } + +// staticToken is a tokenSource that always returns token. +func staticToken(token string) tokenSource { + return tokenFunc(func(context.Context) (string, error) { return token, nil }) +} + +// newTestCredentials is NewCredentials with token in place of a strategy: +// the same type, so the same consume-on-refusal and no-Close semantics. +// A nil token is refused as NewCredentials refuses a nil strategy. +func newTestCredentials(clientID string, key *ClientKey, token tokenSource) Credentials { + return &explicitCredentials{clientID: clientID, key: key, token: token} +} + +// withZeroKMSURL points the client at a ZeroKMS stub. There is no public +// option for it: applications take the endpoint from the token, or from +// CS_ZEROKMS_HOST. +func withZeroKMSURL(url string) ClientOption { + return func(o *clientOptions) { o.zerokmsURL = url } +} + +// The hooks the external tests (package encrypt_test, which can import the +// generated test types this package cannot) reach the internals through. + +// WithZeroKMSURL is withZeroKMSURL for the external tests. +func WithZeroKMSURL(url string) ClientOption { return withZeroKMSURL(url) } + +// Sends is how many ZeroKMS requests the client has made. +func Sends(c *Client) int64 { return c.transport.sends.Load() } + +// ResetSends zeroes the count. +func ResetSends(c *Client) { c.transport.sends.Store(0) } + +// GuestMemory is a copy of the guest's linear memory, for residency scans. +func GuestMemory(t *testing.T, c *Client) []byte { + t.Helper() + mem := c.inst.module.Memory() + view, ok := mem.Read(0, mem.Size()) + if !ok { + t.Fatal("cannot read guest memory") + } + return append([]byte(nil), view...) +} + +// deterministicGuestPath is the deterministic-kms test build, which `mise +// run wasm:guest:build:deterministic` writes under testdata: a directory +// `go build` and the package's `//go:embed wasm` ignore, so the test build +// is read from disk here and embedded in no binary. +const deterministicGuestPath = "testdata/stack_encrypt_guest_deterministic.wasm" + +// ErrDeterministicGuestNotBuilt says the test build is absent. +var ErrDeterministicGuestNotBuilt = errors.New("encrypt: deterministic guest not built; run `mise run wasm:guest:build:deterministic`") + +// NewDeterministicClient is a client over the deterministic-kms test build +// of the guest, seeded: every key derives from the seed and the context, so +// it opens what the Rust record fixture sealed under the same seed and +// needs no ZeroKMS. ErrDeterministicGuestNotBuilt when the build is absent. +func NewDeterministicClient(ctx context.Context, seed [32]byte) (*Client, error) { + wasm, err := os.ReadFile(deterministicGuestPath) + if err != nil { + return nil, ErrDeterministicGuestNotBuilt + } + tr := &transport{rt: refusingTransport{}, token: noToken{}} + inst, err := newInstance(ctx, wasm, tr, guest.BestEffort) + if err != nil { + return nil, err + } + c := newClient(inst, tr) + encoded, err := vcffi.Marshal(seed[:]) + if err != nil { + _ = c.Close() + return nil, err + } + out, err := c.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.cipherInit, buf(encoded)) + }) + if err != nil { + _ = c.Close() + return nil, fmt.Errorf("encrypt: deterministic cipher init: %w", err) + } + if len(out) != len(KeysetID{}) { + _ = c.Close() + return nil, fmt.Errorf("%w: cipher init returned %d bytes for the keyset id", ErrInternal, len(out)) + } + copy(c.def[:], out) + return c, nil +} + +// RawClient is a guest that was never given a client key: every well-formed +// operation is ErrState, every malformed one ErrEncoding, and the checker +// exports work. +func RawClient(t *testing.T, wasm []byte) *Client { + t.Helper() + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: staticToken("t")}, guest.BestEffort) + if err != nil { + t.Fatal(err) + } + c := newClient(inst, nil) + t.Cleanup(func() { _ = c.Close() }) + return c +} + +// EmbeddedGuest is the embedded guest's bytes, or ErrGuestNotBuilt. +func EmbeddedGuest() ([]byte, error) { return embeddedGuest() } + +// 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) } diff --git a/languages/golang/encrypt/fixture_test.go b/languages/golang/encrypt/fixture_test.go new file mode 100644 index 000000000..e992a9679 --- /dev/null +++ b/languages/golang/encrypt/fixture_test.go @@ -0,0 +1,157 @@ +package encrypt_test + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/internal/testusers" +) + +// The record fixture is the proof that generated Go code is a third author +// of one declaration (ADR-0007, amended): the typed Rust chain and the data +// plan lowering each sealed packages/stack-encrypt/tests/fixtures/ +// record_lowering.json under a deterministic key source, and the generated +// testusers package — the same context, identities, kinds and indexes as +// tags — opens both records through the guest built over the same source +// and derives the same term bytes. Skipped when the deterministic test +// build of the guest is absent. + +type recordFixture struct { + KeySource struct { + Kind string `json:"kind"` + Seed string `json:"seed"` + } `json:"key_source"` + KeysetID string `json:"keyset_id"` + Plaintext struct { + Age uint32 `json:"age"` + Email string `json:"email"` + ID uint32 `json:"id"` + Notes string `json:"notes"` + } `json:"plaintext"` + Records map[string]map[string]map[string]json.RawMessage `json:"records"` +} + +func readFixture(t *testing.T) recordFixture { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "..", "packages", "stack-encrypt", "tests", "fixtures", "record_lowering.json")) + if err != nil { + t.Fatal(err) + } + var f recordFixture + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + if f.KeySource.Kind != "deterministic-sha256" || f.KeysetID != "00000000-0000-0000-0000-000000000000" { + t.Fatalf("the fixture's key source changed shape: %+v", f.KeySource) + } + return f +} + +func hexField(t *testing.T, rec map[string]map[string]json.RawMessage, field, output string) []byte { + t.Helper() + raw, ok := rec[field][output] + if !ok { + t.Fatalf("the fixture record has no %s.%s", field, output) + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + t.Fatal(err) + } + b, err := hex.DecodeString(s) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestGeneratedCodeOpensTheRustRecordFixture(t *testing.T) { + f := readFixture(t) + seedBytes, err := hex.DecodeString(f.KeySource.Seed) + if err != nil || len(seedBytes) != 32 { + t.Fatalf("seed: %v (%d bytes)", err, len(seedBytes)) + } + c, err := encrypt.NewDeterministicClient(context.Background(), [32]byte(seedBytes)) + if errors.Is(err, encrypt.ErrDeterministicGuestNotBuilt) || errors.Is(err, encrypt.ErrGuestNotBuilt) { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + defer c.Close() + ctx := context.Background() + // The fixture's keyset is the nil UUID: the fake source's default. + cipher := c.DefaultKeyset() + if id, err := cipher.KeysetID(ctx); err != nil || id != (encrypt.KeysetID{}) { + t.Fatalf("default keyset = %v, %v; want the nil id", id, err) + } + + for author, rec := range f.Records { + t.Run(author, func(t *testing.T) { + // The stored record, as a program that read the columns Rust + // wrote would hold it. The passthrough id never crossed the + // binding on either side. + stored := testusers.EncryptedUser{ + ID: int64(f.Plaintext.ID), + Age: testusers.EncryptedUserAge{ + Ciphertext: hexField(t, rec, "age", "c"), + Equality: hexField(t, rec, "age", "eq"), + Ore: hexField(t, rec, "age", "ore"), + }, + Email: testusers.EncryptedUserEmail{ + Ciphertext: hexField(t, rec, "email", "c"), + Equality: hexField(t, rec, "email", "eq"), + Match: hexField(t, rec, "email", "match"), + }, + Notes: testusers.EncryptedUserNotes{Ciphertext: hexField(t, rec, "notes", "c")}, + } + for name, d := range map[string]encrypt.Decrypter{"cipher": cipher, "client": c} { + back, err := testusers.Decrypt(ctx, d, []testusers.EncryptedUser{stored}) + if err != nil { + t.Fatalf("Decrypt through the %s: %v", name, err) + } + want := testusers.User{ID: int64(f.Plaintext.ID), Age: f.Plaintext.Age, Email: f.Plaintext.Email, Notes: f.Plaintext.Notes} + if back[0] != want { + t.Fatalf("Decrypt through the %s = %+v, want %+v", name, back[0], want) + } + } + // The terms Go derives are the bytes Rust stored. + eq, err := testusers.Fields.Age.Equality(ctx, cipher, f.Plaintext.Age) + if err != nil || !eq.Equal(stored.Age.Equality) { + t.Errorf("age equality: %v, equal=%v", err, eq.Equal(stored.Age.Equality)) + } + ore, err := testusers.Fields.Age.Ore(ctx, cipher, f.Plaintext.Age) + if err != nil || !bytes.Equal(ore, stored.Age.Ore) { + t.Errorf("age ore: %v, equal=%v", err, bytes.Equal(ore, stored.Age.Ore)) + } + emailEq, err := testusers.Fields.Email.Equality(ctx, cipher, f.Plaintext.Email) + if err != nil || !emailEq.Equal(stored.Email.Equality) { + t.Errorf("email equality: %v, equal=%v", err, emailEq.Equal(stored.Email.Equality)) + } + match, err := testusers.Fields.Email.Match(ctx, cipher, f.Plaintext.Email) + if err != nil || !bytes.Equal(match, stored.Email.Match) { + t.Errorf("email match: %v, equal=%v", err, bytes.Equal(match, stored.Email.Match)) + } + // A fresh Go record of the same plaintext derives the same terms. + again, err := testusers.Encrypt(ctx, cipher, []testusers.User{{ID: int64(f.Plaintext.ID), Age: f.Plaintext.Age, Email: f.Plaintext.Email, Notes: f.Plaintext.Notes}}) + if err != nil { + t.Fatal(err) + } + if !again[0].Age.Equality.Equal(stored.Age.Equality) || !bytes.Equal(again[0].Email.Match, stored.Email.Match) || !bytes.Equal(again[0].Age.Ore, stored.Age.Ore) { + t.Error("a Go record of the fixture's plaintext derives other terms than Rust did") + } + // A leaf moved under another field's label does not open. + moved := stored + moved.Notes.Ciphertext = stored.Email.Ciphertext + if _, err := testusers.Decrypt(ctx, cipher, []testusers.EncryptedUser{moved}); err == nil { + t.Error("a leaf opened under another field's label") + } + }) + } +} diff --git a/languages/golang/encrypt/generated_paths_test.go b/languages/golang/encrypt/generated_paths_test.go new file mode 100644 index 000000000..efd787fbc --- /dev/null +++ b/languages/golang/encrypt/generated_paths_test.go @@ -0,0 +1,139 @@ +package encrypt_test + +import ( + "bytes" + "context" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/internal/testmember" + "github.com/cipherstash/stack/languages/golang/encrypt/internal/testpolicy" + "github.com/cipherstash/stack/languages/golang/encrypt/internal/testusers" +) + +// The generator writes a file three ways: from a struct's own tags, from +// tags declared for a type in another package (-for), and from a policy +// (stashgen.Generate). The golden tests only compile the second and third; +// this runs them against the deterministic guest, beside the tag path, for +// one declaration (testusers.User's) over one set of values, and holds the +// three to the same bytes. + +var members = []testmember.Member{ + {ID: 1, Age: 34, Email: "alice@example.com", Notes: "likes cats"}, + {ID: 2, Age: 29, Email: "bob@example.com", Notes: "likes dogs"}, +} + +func asUsers(ms []testmember.Member) []testusers.User { + out := make([]testusers.User, len(ms)) + for i, m := range ms { + out[i] = testusers.User{ID: m.ID, Age: m.Age, Email: m.Email, Notes: m.Notes} + } + return out +} + +func TestGeneratedCodeFromForAndFromAPolicyRunsAgainstTheGuest(t *testing.T) { + c := deterministicClient(t) + ctx := context.Background() + cipher := c.DefaultKeyset() + + tagged, err := testusers.Encrypt(ctx, cipher, asUsers(members)) + if err != nil { + t.Fatal(err) + } + forPath, err := testusers.EncryptMember(ctx, cipher, members) + if err != nil { + t.Fatalf("the -for path: %v", err) + } + policyPath, err := testpolicy.Encrypt(ctx, cipher, members) + if err != nil { + t.Fatalf("the policy path: %v", err) + } + + // One declaration, three authors, the same terms. + for i := range members { + for name, got := range map[string]struct{ ageEq, ageOre, emailEq, emailMatch []byte }{ + "the -for path": {forPath[i].Age.Equality, forPath[i].Age.Ore, forPath[i].Email.Equality, forPath[i].Email.Match}, + "the policy path": {policyPath[i].Age.Equality, policyPath[i].Age.Ore, policyPath[i].Email.Equality, policyPath[i].Email.Match}, + } { + want := tagged[i] + if !bytes.Equal(got.ageEq, want.Age.Equality) || !bytes.Equal(got.ageOre, want.Age.Ore) || !bytes.Equal(got.emailEq, want.Email.Equality) || !bytes.Equal(got.emailMatch, want.Email.Match) { + t.Errorf("row %d: %s derives other terms than the tag path", i, name) + } + } + if len(forPath[i].Notes.Ciphertext) == 0 || len(policyPath[i].Notes.Ciphertext) == 0 || forPath[i].ID != members[i].ID || policyPath[i].ID != members[i].ID { + t.Errorf("row %d: outputs missing: %+v %+v", i, forPath[i], policyPath[i]) + } + } + + // Each path opens its own rows, through the cipher and the client. + for _, d := range []struct { + name string + by encrypt.Decrypter + }{{"cipher", cipher}, {"client", c}} { + back, err := testusers.DecryptMember(ctx, d.by, forPath) + if err != nil { + t.Fatalf("DecryptMember through the %s: %v", d.name, err) + } + for i := range members { + if back[i] != members[i] { + t.Fatalf("the -for path through the %s: row %d = %+v, want %+v", d.name, i, back[i], members[i]) + } + } + back, err = testpolicy.Decrypt(ctx, d.by, policyPath) + if err != nil { + t.Fatalf("testpolicy.Decrypt through the %s: %v", d.name, err) + } + for i := range members { + if back[i] != members[i] { + t.Fatalf("the policy path through the %s: row %d = %+v, want %+v", d.name, i, back[i], members[i]) + } + } + } + + // And each opens the tag path's rows: the stored shape is one shape. + crossed := make([]testusers.EncryptedMember, len(tagged)) + for i, e := range tagged { + crossed[i] = testusers.EncryptedMember{ + ID: e.ID, + Age: testusers.EncryptedMemberAge(e.Age), + Email: testusers.EncryptedMemberEmail(e.Email), + Notes: testusers.EncryptedMemberNotes(e.Notes), + } + } + if back, err := testusers.DecryptMember(ctx, cipher, crossed); err != nil || back[0] != members[0] { + t.Fatalf("the -for path opening the tag path's rows: %v %+v", err, back) + } + viaPolicy := make([]testpolicy.EncryptedMember, len(tagged)) + for i, e := range tagged { + viaPolicy[i] = testpolicy.EncryptedMember{ + ID: e.ID, + Age: testpolicy.EncryptedMemberAge(e.Age), + Email: testpolicy.EncryptedMemberEmail(e.Email), + Notes: testpolicy.EncryptedMemberNotes(e.Notes), + } + } + if back, err := testpolicy.Decrypt(ctx, cipher, viaPolicy); err != nil || back[1] != members[1] { + t.Fatalf("the policy path opening the tag path's rows: %v %+v", err, back) + } + + // A term derived through each path's Fields is the tag path's. + probe, err := testusers.Fields.Email.Equality(ctx, cipher, "bob@example.com") + if err != nil { + t.Fatal(err) + } + forProbe, err := testusers.MemberFields.Email.Equality(ctx, cipher, "bob@example.com") + if err != nil || !forProbe.Equal(probe) { + t.Fatalf("the -for path's probe: %v, equal=%v", err, forProbe.Equal(probe)) + } + policyProbe, err := testpolicy.Fields.Email.Equality(ctx, cipher, "bob@example.com") + if err != nil || !policyProbe.Equal(probe) { + t.Fatalf("the policy path's probe: %v, equal=%v", err, policyProbe.Equal(probe)) + } + ore, err := testpolicy.Fields.Age.Ore(ctx, cipher, 29) + if err != nil || !bytes.Equal(ore, tagged[1].Age.Ore) { + t.Fatalf("the policy path's ORE probe: %v", err) + } + if !probe.Equal(tagged[1].Email.Equality) || probe.Equal(tagged[0].Email.Equality) { + t.Error("the probe does not single out bob's row") + } +} diff --git a/languages/golang/encrypt/gensupport/codec.go b/languages/golang/encrypt/gensupport/codec.go new file mode 100644 index 000000000..284e6d83e --- /dev/null +++ b/languages/golang/encrypt/gensupport/codec.go @@ -0,0 +1,369 @@ +package gensupport + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/internal/record" +) + +// Values is a struct's field values by declared name: what Source gives and +// what Value reads. Passthrough fields are in it too. +type Values map[string]any + +// Output is what one field became: the passthrough value, or the ciphertext +// and each term the field declares. EQL is the EQL value of an encrypt_into +// field, once the engine produces one. +type Output struct { + Value any + Ciphertext encrypt.Ciphertext + Equality encrypt.EqualityTerm + Match encrypt.MatchTerm + Ore encrypt.OreTerm + Ope encrypt.OpeTerm + JSON encrypt.JSONTerm + EQL []byte +} + +// Record is one record's fields by declared name, as Seal reads it and Open +// writes it. +type Record map[string]Output + +// Generated is what a generated file gives the library for one type: the +// declaration, and the four conversions between the plaintext type P, the +// encrypted type E and the data the engine reads and returns. A file writes +// these without reflection: each is a function over named fields. +type Generated[P, E any] struct { + // TypeName is how notices print the type: "User" or "crm.Contact". + TypeName string + // Declaration is the struct's tags as data. + Declaration Declaration + // PrintsPlaintext is true when P has no String and LogValue methods, so + // the running program warns once that P prints its sealed fields. + PrintsPlaintext bool + // Unexported are the unexported fields with no tag, which are neither + // encrypted nor stored; the running program warns once. + Unexported []string + // Source reads every declared field of a value. + Source func(P) Values + // Seal builds the encrypted value from the engine's outputs. + Seal func(Record) (E, error) + // Open reads the engine's inputs from an encrypted value. + Open func(E) Record + // Value builds the plaintext from the opened fields. + Value func(E, Values) (P, error) +} + +// Codec encrypts and decrypts one generated type. +type Codec[P, E any] struct { + g Generated[P, E] + plan *record.Plan + err error + notice sync.Once +} + +// New builds the codec for a generated type. A declaration the engine cannot +// run is reported by the first call, not here: nothing in this package +// panics, and a package-level var cannot return an error. +func New[P, E any](g Generated[P, E]) *Codec[P, E] { + c := &Codec[P, E]{g: g} + c.plan, c.err = g.Declaration.plan() + if c.err == nil && (g.Source == nil || g.Seal == nil || g.Open == nil || g.Value == nil) { + c.err = fmt.Errorf("gensupport: %s: the generated file is incomplete", g.TypeName) + } + return c +} + +func (c *Codec[P, E]) notices() { + c.notice.Do(func() { + NoticeUntagged(c.g.TypeName, c.g.Unexported) + if c.g.PrintsPlaintext { + NoticePrintsPlaintext(c.g.TypeName) + } + }) +} + +// Encrypt seals every value, with one request for each 500 sealed values. The result has one element for +// each value, in the same order. +func (c *Codec[P, E]) Encrypt(ctx context.Context, cipher *encrypt.Cipher, values []P) ([]E, error) { + c.notices() + if c.err != nil { + return nil, c.err + } + if cipher == nil { + return nil, fmt.Errorf("gensupport: %s: Encrypt needs a cipher", c.g.TypeName) + } + rows := make([]record.Source, len(values)) + passthrough := make([]map[string]any, len(values)) + for i, v := range values { + vals := c.g.Source(v) + if vals == nil { + return nil, fmt.Errorf("gensupport: %s: value %d is nil", c.g.TypeName, i) + } + row, keep, err := c.split(vals) + if err != nil { + return nil, fmt.Errorf("gensupport: %s: value %d: %w", c.g.TypeName, i, err) + } + if c.g.Declaration.opaque { + if row[OpaqueField], err = opaqueBytes(row[OpaqueField]); err != nil { + return nil, fmt.Errorf("gensupport: %s: value %d: %w", c.g.TypeName, i, err) + } + } + rows[i], passthrough[i] = row, keep + } + sealed, err := cipher.Seal(ctx, c.plan, rows) + if err != nil { + return nil, err + } + out := make([]E, len(values)) + for i, s := range sealed { + rec := make(Record, len(c.g.Declaration.fields)) + for name, v := range passthrough[i] { + rec[name] = Output{Value: v} + } + for name, o := range s { + rec[name] = outputOf(o) + } + if out[i], err = c.g.Seal(rec); err != nil { + return nil, fmt.Errorf("gensupport: %s: value %d: %w", c.g.TypeName, i, err) + } + } + return out, nil +} + +// Decrypt opens every value, with one request for each 500 sealed values. +func (c *Codec[P, E]) Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []E) ([]P, error) { + c.notices() + if c.err != nil { + return nil, c.err + } + if d == nil { + return nil, fmt.Errorf("gensupport: %s: Decrypt needs a Cipher or a Client", c.g.TypeName) + } + records := make([]record.Sealed, len(encrypted)) + passthrough := make([]map[string]any, len(encrypted)) + for i, e := range encrypted { + rec := c.g.Open(e) + records[i] = make(record.Sealed, len(c.plan.Fields)) + passthrough[i] = map[string]any{} + for _, f := range c.g.Declaration.fields { + o, ok := rec[f.name] + switch { + case f.verb == verbOmit: + continue + case !ok: + return nil, fmt.Errorf("gensupport: %s: value %d: Open gave no field %q", c.g.TypeName, i, f.name) + case f.verb == verbContextField: + label, isString := o.Value.(string) + if !isString { + return nil, fmt.Errorf("gensupport: %s: value %d: the context field %q holds a %T, not a string", c.g.TypeName, i, f.name, o.Value) + } + records[i][f.name] = record.Outputs{Context: label} + case f.sealed(): + records[i][f.name] = record.Outputs{Ciphertext: o.Ciphertext} + default: + passthrough[i][f.name] = o.Value + } + } + } + sources, err := d.Open(ctx, c.plan, records) + if err != nil { + return nil, err + } + out := make([]P, len(encrypted)) + for i, src := range sources { + vals := make(Values, len(c.g.Declaration.fields)) + for name, v := range passthrough[i] { + vals[name] = v + } + for name, v := range src { + vals[name] = v + } + if out[i], err = c.g.Value(encrypted[i], vals); err != nil { + return nil, fmt.Errorf("gensupport: %s: value %d: %w", c.g.TypeName, i, err) + } + } + return out, nil +} + +// split sorts a value's fields into what crosses the binding and what stays: +// every declared field must be present and nothing else may be. +func (c *Codec[P, E]) split(vals Values) (record.Source, map[string]any, error) { + row := make(record.Source, len(c.plan.Fields)) + keep := map[string]any{} + for _, f := range c.g.Declaration.fields { + if f.verb == verbOmit { + continue + } + v, ok := vals[f.name] + if !ok { + return nil, nil, fmt.Errorf("the generated Source gave no field %q", f.name) + } + if f.crosses() { + row[f.name] = v + } else { + keep[f.name] = v + } + } + if len(vals) != len(row)+len(keep) { + for name := range vals { + if _, ok := row[name]; ok { + continue + } + if _, ok := keep[name]; ok { + continue + } + return nil, nil, fmt.Errorf("the generated Source gave a field %q the declaration does not name", name) + } + } + return row, keep, nil +} + +func outputOf(o record.Outputs) Output { + if o.Context != "" { + // The context field comes back as the passthrough it is. + return Output{Value: o.Context} + } + out := Output{Ciphertext: o.Ciphertext} + for k, term := range o.Terms { + switch k { + case record.Equality: + out.Equality = term + case record.Match: + out.Match = term + case record.Ore: + out.Ore = term + case record.Ope: + out.Ope = term + } + } + return out +} + +// Passthrough reads a passthrough field from a record, as the Go type the +// struct declares it. +func Passthrough[T any](rec Record, name string) (T, error) { + o, ok := rec[name] + if !ok { + var zero T + return zero, fmt.Errorf("gensupport: no passthrough field %q", name) + } + v, ok := o.Value.(T) + if !ok { + var zero T + if o.Value == nil && any(zero) == nil { + // A nil interface value asserts to no type. For an interface T + // it is the value the struct held. + return zero, nil + } + return zero, fmt.Errorf("gensupport: passthrough field %q holds a %T, not a %T", name, o.Value, zero) + } + return v, nil +} + +// Get reads one opened field as the Go type the struct declares it. A +// passthrough field is the Go value the struct held, whatever its type, and +// comes back as it is. A sealed field comes back from the engine at its +// declared wire kind; Get converts within that kind's family (a uint32 into +// a uint8 that holds it) and refuses anything else, so a value that opens to +// another type is an error and never a silent zero. A defined type over a +// scalar (type Email string) is read at its underlying type and converted by +// the generated code. +func Get[T any](vals Values, name string) (T, error) { + var out T + v, ok := vals[name] + if !ok { + return out, fmt.Errorf("gensupport: the opened value has no field %q", name) + } + if exact, ok := v.(T); ok { + return exact, nil + } + if v == nil && any(out) == nil { + // A nil interface value asserts to no type. For an interface T, + // such as a passthrough error or any, it is the value the struct + // held. + return out, nil + } + if err := convert(v, &out); err != nil { + return out, fmt.Errorf("gensupport: field %q: %w: %w", name, encrypt.ErrEncoding, err) + } + return out, nil +} + +// Records wraps a codec with a model's conversions, for separate columns: +// EncryptRows and DecryptRows return and take the model. +func Records[P, E, R any](codec *Codec[P, E], to func(E) R, from func(R) E) *RecordsCodec[P, R] { + return &RecordsCodec[P, R]{ + encrypt: func(ctx context.Context, c *encrypt.Cipher, values []P) ([]R, error) { + es, err := codec.Encrypt(ctx, c, values) + if err != nil { + return nil, err + } + rs := make([]R, len(es)) + for i, e := range es { + rs[i] = to(e) + } + return rs, nil + }, + decrypt: func(ctx context.Context, d encrypt.Decrypter, rows []R) ([]P, error) { + es := make([]E, len(rows)) + for i, r := range rows { + es[i] = from(r) + } + return codec.Decrypt(ctx, d, es) + }, + } +} + +// RecordsCodec encrypts into and decrypts from a model. +type RecordsCodec[P, R any] struct { + encrypt func(context.Context, *encrypt.Cipher, []P) ([]R, error) + decrypt func(context.Context, encrypt.Decrypter, []R) ([]P, error) +} + +// Encrypt seals every value into a model row, with one request for each 500 sealed values. +func (c *RecordsCodec[P, R]) Encrypt(ctx context.Context, cipher *encrypt.Cipher, values []P) ([]R, error) { + return c.encrypt(ctx, cipher, values) +} + +// Decrypt opens every model row, with one request for each 500 sealed values. +func (c *RecordsCodec[P, R]) Decrypt(ctx context.Context, d encrypt.Decrypter, rows []R) ([]P, error) { + return c.decrypt(ctx, d, rows) +} + +// opaqueBytes is an opaque struct's fields as the one value the engine +// seals: a JSON document of the generated shape struct, so the struct is one +// column and every field type encoding/json round-trips comes back as it +// was. +func opaqueBytes(fields any) ([]byte, error) { + encoded, err := json.Marshal(fields) + if err != nil { + // encoding/json refuses NaN and the infinities, which a sealed float + // field outside an opaque struct accepts. + // Its error is not wrapped: encoding/json's text quotes the value. + return nil, fmt.Errorf("%w: the opaque value does not encode as JSON (NaN, an infinity, or a type encoding/json refuses)", encrypt.ErrEncoding) + } + return encoded, nil +} + +// Opaque reads an opened opaque value into the generated shape struct: the +// JSON document the engine returned, decoded into the exact Go types the +// struct declares. Generated code calls it from Value. +func Opaque[T any](vals Values, out *T) error { + v, ok := vals[OpaqueField] + if !ok { + return fmt.Errorf("gensupport: the opened value has no field %q", OpaqueField) + } + encoded, ok := v.([]byte) + if !ok { + return fmt.Errorf("gensupport: %w: the opaque value opened as %T, not bytes", encrypt.ErrEncoding, v) + } + if err := json.Unmarshal(encoded, out); err != nil { + // Its error is not wrapped: encoding/json's text quotes the value. + return fmt.Errorf("gensupport: %w: the opaque value does not decode into a %T", encrypt.ErrEncoding, *out) + } + return nil +} diff --git a/languages/golang/encrypt/gensupport/convert.go b/languages/golang/encrypt/gensupport/convert.go new file mode 100644 index 000000000..22162f8ea --- /dev/null +++ b/languages/golang/encrypt/gensupport/convert.go @@ -0,0 +1,392 @@ +package gensupport + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "reflect" + "strconv" + + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// convert writes an opened sealed value into out, a pointer to the Go type +// the struct declares. The engine returns a value at the field's declared +// kind — int32, int64, uint32, uint64, float32, float64, string, []byte, +// bool, or a vcvalue.Object for a composite — and the struct's type is in +// the same family, narrower at most. A value outside the target's range, or +// of another family, is an error. +// +// An error names types and positions, never the value: the value is +// decrypted plaintext, and an error is what a program logs. +// +// The switch names the built-in types; a type defined over one (type Status +// string, time.Duration), or a slice or map of any readable type, is read +// through its underlying type by [convertVia], the one place this package +// uses reflection. Generated code uses none: it hands Get the field's type +// and the engine's value, and reads a value back. +func convert(v any, out any) error { + // A JSON number — what an opaque document carries — widens to its + // family's widest type, and the family's range check applies below. + if n, ok := v.(json.Number); ok { + var err error + if v, err = widenNumber(n, out); err != nil { + return err + } + } + // A nil slice or map comes back as nil: the zero value it was. + if v == nil { + switch out.(type) { + case *[]byte, *[]any, *[]string, *[]int64, *[]int32, *[]uint32, *[]uint64, *[]float64, *[]bool, *[][]byte, *Values, *map[string]any, *any: + return nil + } + return fmt.Errorf("opened as nothing, and %T holds a value", out) + } + switch out := out.(type) { + case *string: + s, ok := v.(string) + if !ok { + return mismatch(v, *out) + } + *out = s + case *[]byte: + b, ok := v.([]byte) + if !ok { + return mismatch(v, *out) + } + *out = append([]byte(nil), b...) + case *bool: + b, ok := v.(bool) + if !ok { + return mismatch(v, *out) + } + *out = b + case *int: + return setInt(v, out, math.MinInt, math.MaxInt) + case *int8: + return setInt(v, out, math.MinInt8, math.MaxInt8) + case *int16: + return setInt(v, out, math.MinInt16, math.MaxInt16) + case *int32: + return setInt(v, out, math.MinInt32, math.MaxInt32) + case *int64: + return setInt(v, out, math.MinInt64, math.MaxInt64) + case *uint: + return setUint(v, out, math.MaxUint) + case *uint8: + return setUint(v, out, math.MaxUint8) + case *uint16: + return setUint(v, out, math.MaxUint16) + case *uint32: + return setUint(v, out, math.MaxUint32) + case *uint64: + return setUint(v, out, math.MaxUint64) + case *float32: + switch f := v.(type) { + case float32: + *out = f + case float64: + if f != 0 && !math.IsInf(f, 0) && !math.IsNaN(f) && (math.Abs(f) > math.MaxFloat32 || math.Abs(f) < math.SmallestNonzeroFloat32) { + return outOfRange(*out) + } + *out = float32(f) + default: + return mismatch(v, *out) + } + case *float64: + switch f := v.(type) { + case float32: + *out = float64(f) + case float64: + *out = f + default: + return mismatch(v, *out) + } + case *Values: + vals, err := valuesOf(v) + if err != nil { + return err + } + *out = vals + case *map[string]any: + vals, err := valuesOf(v) + if err != nil { + return err + } + *out = map[string]any(vals) + case *[]any: + items, ok := v.([]any) + if !ok { + return mismatch(v, *out) + } + *out = append([]any(nil), items...) + case *[]string: + return setSlice(v, out) + case *[]int64: + return setSlice(v, out) + case *[]int32: + return setSlice(v, out) + case *[]uint32: + return setSlice(v, out) + case *[]uint64: + return setSlice(v, out) + case *[]float64: + return setSlice(v, out) + case *[]bool: + return setSlice(v, out) + case *[][]byte: + return setSlice(v, out) + case *any: + *out = v + default: + return convertVia(v, out) + } + return nil +} + +// convertVia reads a value into a type the switch in convert does not name: +// a type defined over a scalar is read as its underlying type and converted; +// a slice or array element by element; a map with string keys entry by +// entry, each value through convert. Anything else is a mismatch. +func convertVia(v any, out any) error { + target := reflect.ValueOf(out) + if target.Kind() != reflect.Pointer || target.IsNil() { + return fmt.Errorf("the opened value is a %T, which %T cannot hold", v, out) + } + elem := target.Elem() + t := elem.Type() + switch t.Kind() { + case reflect.Bool, reflect.String, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + // A defined type: read the underlying type, then convert. + under := reflect.New(underlying(t)) + if err := convert(v, under.Interface()); err != nil { + return err + } + elem.Set(under.Elem().Convert(t)) + return nil + case reflect.Slice: + if t.Elem().Kind() == reflect.Uint8 { + var b []byte + if err := convert(v, &b); err != nil { + return err + } + elem.Set(reflect.ValueOf(b).Convert(t)) + return nil + } + items, ok := v.([]any) + if !ok { + return fmt.Errorf("opened as %T, not %s", v, t) + } + result := reflect.MakeSlice(t, len(items), len(items)) + for i, item := range items { + if err := convert(item, result.Index(i).Addr().Interface()); err != nil { + return fmt.Errorf("element %d: %w", i, err) + } + } + elem.Set(result) + return nil + case reflect.Array: + items, ok := v.([]any) + if !ok || len(items) != t.Len() { + return fmt.Errorf("opened as %T with %d elements, not %s", v, len(items), t) + } + result := reflect.New(t).Elem() + for i, item := range items { + if err := convert(item, result.Index(i).Addr().Interface()); err != nil { + return fmt.Errorf("element %d: %w", i, err) + } + } + elem.Set(result) + return nil + case reflect.Map: + if t.Key().Kind() != reflect.String { + return fmt.Errorf("%s has a key that is not a string", t) + } + vals, err := valuesOf(v) + if err != nil { + return err + } + result := reflect.MakeMapWithSize(t, len(vals)) + for key, item := range vals { + slot := reflect.New(t.Elem()) + if err := convert(item, slot.Interface()); err != nil { + return fmt.Errorf("an entry of %s: %w", t, err) + } + result.SetMapIndex(reflect.ValueOf(key).Convert(t.Key()), slot.Elem()) + } + elem.Set(result) + return nil + } + return fmt.Errorf("the opened value is a %T, which this field's type %s cannot hold", v, t) +} + +// underlying is the built-in type a defined scalar type is declared over. +func underlying(t reflect.Type) reflect.Type { + switch t.Kind() { + case reflect.Bool: + return reflect.TypeFor[bool]() + case reflect.String: + return reflect.TypeFor[string]() + case reflect.Int: + return reflect.TypeFor[int]() + case reflect.Int8: + return reflect.TypeFor[int8]() + case reflect.Int16: + return reflect.TypeFor[int16]() + case reflect.Int32: + return reflect.TypeFor[int32]() + case reflect.Int64: + return reflect.TypeFor[int64]() + case reflect.Uint: + return reflect.TypeFor[uint]() + case reflect.Uint8: + return reflect.TypeFor[uint8]() + case reflect.Uint16: + return reflect.TypeFor[uint16]() + case reflect.Uint32: + return reflect.TypeFor[uint32]() + case reflect.Uint64: + return reflect.TypeFor[uint64]() + case reflect.Float32: + return reflect.TypeFor[float32]() + } + return reflect.TypeFor[float64]() +} + +// widenNumber reads a JSON number as the widest value of the target's +// family: int64 for a signed target, uint64 for an unsigned one, float64 for +// a float. The family conversion then applies its range check. +func widenNumber(n json.Number, out any) (any, error) { + kind := reflect.TypeOf(out).Elem().Kind() + switch kind { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + i, err := strconv.ParseInt(string(n), 10, 64) + if err != nil { + return nil, errors.New("the stored number is not an integer that fits an int64") + } + return i, nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + u, err := strconv.ParseUint(string(n), 10, 64) + if err != nil { + return nil, errors.New("the stored number is not an integer that fits a uint64") + } + return u, nil + case reflect.Float32, reflect.Float64, reflect.Interface: + f, err := n.Float64() + if err != nil { + // strconv's error quotes the number. + return nil, errors.New("the stored number does not fit a float64") + } + return f, nil + } + return n, nil +} + +func mismatch(v, want any) error { + return fmt.Errorf("opened as %T, not %T", v, want) +} + +// outOfRange names the target type and not the value, which is plaintext. +func outOfRange(want any) error { + return fmt.Errorf("the stored value does not fit a %T", want) +} + +// setInt writes an integer of any decoded width into a signed target, within +// its range. +func setInt[T ~int | ~int8 | ~int16 | ~int32 | ~int64](v any, out *T, lo, hi int64) error { + var n int64 + switch i := v.(type) { + case int32: + n = int64(i) + case int64: + n = i + case uint32: + n = int64(i) + case uint64: + if i > math.MaxInt64 { + return outOfRange(*out) + } + n = int64(i) + default: + return mismatch(v, *out) + } + if n < lo || n > hi { + return outOfRange(*out) + } + *out = T(n) + return nil +} + +// setUint writes an integer of any decoded width into an unsigned target, +// within its range. +func setUint[T ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64](v any, out *T, hi uint64) error { + var n uint64 + switch i := v.(type) { + case uint32: + n = uint64(i) + case uint64: + n = i + case int32: + if i < 0 { + return outOfRange(*out) + } + n = uint64(i) + case int64: + if i < 0 { + return outOfRange(*out) + } + n = uint64(i) + default: + return mismatch(v, *out) + } + if n > hi { + return outOfRange(*out) + } + *out = T(n) + return nil +} + +// setSlice converts each element of an opened array. +func setSlice[E any](v any, out *[]E) error { + items, ok := v.([]any) + if !ok { + return mismatch(v, *out) + } + result := make([]E, len(items)) + for i, item := range items { + if err := convert(item, &result[i]); err != nil { + return fmt.Errorf("element %d: %w", i, err) + } + } + *out = result + return nil +} + +// valuesOf reads an opened composite as Values, nested objects included. +func valuesOf(v any) (Values, error) { + switch obj := v.(type) { + case vcvalue.Object: + vals := make(Values, len(obj)) + for _, f := range obj { + if inner, ok := f.Value.(vcvalue.Object); ok { + nested, err := valuesOf(inner) + if err != nil { + return nil, err + } + vals[f.Key] = nested + continue + } + vals[f.Key] = f.Value + } + return vals, nil + case Values: + return obj, nil + case map[string]any: + return Values(obj), nil + } + return nil, fmt.Errorf("opened as %T, not an object", v) +} diff --git a/languages/golang/encrypt/gensupport/declaration.go b/languages/golang/encrypt/gensupport/declaration.go new file mode 100644 index 000000000..6d5237eff --- /dev/null +++ b/languages/golang/encrypt/gensupport/declaration.go @@ -0,0 +1,251 @@ +package gensupport + +import ( + "errors" + "fmt" + "slices" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/internal/record" +) + +// Kind is a field's wire type: the data form of the Rust chain's `::`, +// chosen by stashgen from the field's Go type. It decides the terms a field +// derives; every field seals as vitaminc's tagged leaf whatever its kind, +// which a Rust record opens when its field is a Value. Every sealed field has a +// scalar kind: stashgen refuses a struct, slice or map outside an opaque +// struct, and an opaque struct seals as Bytes (one JSON document). Untyped +// names a field with no declared type and is not what generated code writes. +type Kind string + +// The kinds. int8, int16 and int32 are Int32; int and int64 are Int64; +// uint8, uint16 and uint32 are Uint32; uint and uint64 are Uint64; []byte is +// Bytes. A type defined over one of these has its underlying kind. +const ( + Untyped Kind = "" + Bool Kind = "bool" + Int32 Kind = "int32" + Int64 Kind = "int64" + Uint32 Kind = "uint32" + Uint64 Kind = "uint64" + Float32 Kind = "float32" + Float64 Kind = "float64" + String Kind = "string" + Bytes Kind = "bytes" +) + +// OpaqueField is the one field of an opaque declaration: the whole struct, +// sealed as one value under /value. The struct crosses the binding +// as one JSON document — the engine seals a composite value as a tree of +// leaves, and an opaque struct is one column — so its field types are what +// JSON carries: scalars, []byte (base64), slices and maps of them. +const OpaqueField = "value" + +type verb uint8 + +const ( + verbPassthrough verb = iota + 1 + verbOmit + verbEncrypt + verbEncryptIndex + verbIndex + verbEncryptInto + verbContextField +) + +type field struct { + name string + identity string + kind Kind + verb verb + indexes []encrypt.Index + eqlType string +} + +// Declaration is a generated struct's declaration as data: its context and, +// for each field, what happens to it. Only generated code builds one, from +// the struct's stash tags, and only generated code reads it. A mistake in a +// declaration is found by stashgen; this type still refuses one, since no +// function in this package panics, and the error comes back from the first +// call through the codec. +type Declaration struct { + context []string + contextField string + opaque bool + fields []field + err error +} + +// Declare starts a declaration with the struct's context: the `context=` +// tag, segments separated by '/'. +func Declare(context string) Declaration { + segments, err := record.ParseContext(context) + if err != nil { + return Declaration{err: fmt.Errorf("gensupport: %v", err)} + } + return Declaration{context: segments} +} + +// DeclareOpaque declares a struct sealed as one value: one field, +// [OpaqueField], encrypted as bytes. +func DeclareOpaque(context string) Declaration { + d := Declare(context) + d.opaque = true + return d.add(field{name: OpaqueField, kind: Bytes, verb: verbEncrypt}) +} + +// DeclareContextField starts a declaration whose context is the named +// field of each value, a string such as "tenants/acme": the `context_field` +// tag, the engine's context_field. Every other field is sealed under that +// value, and the field is stored as it is, in the clear and unauthenticated +// like a passthrough, so a row names its own context. The field's name is +// a label segment on the wire, so it must be a plain one. +func DeclareContextField(name string) Declaration { + if err := record.CheckSegment(name); err != nil { + return Declaration{err: fmt.Errorf("gensupport: context field %q %v", name, err)} + } + d := Declaration{contextField: name} + return d.add(field{name: name, kind: String, verb: verbContextField}) +} + +// Passthrough stores the field as it is. It stays on the host: see the +// record package. +func (d Declaration) Passthrough(name string) Declaration { + return d.add(field{name: name, verb: verbPassthrough}) +} + +// Encrypt seals the field with no index. +func (d Declaration) Encrypt(name string, kind Kind) Declaration { + return d.add(field{name: name, kind: kind, verb: verbEncrypt}) +} + +// EncryptIndex seals the field and derives each index beside it. +func (d Declaration) EncryptIndex(name string, kind Kind, indexes ...encrypt.Index) Declaration { + return d.add(field{name: name, kind: kind, verb: verbEncryptIndex, indexes: indexes}) +} + +// Index derives the indexes alone, with no ciphertext. +func (d Declaration) Index(name string, kind Kind, indexes ...encrypt.Index) Declaration { + return d.add(field{name: name, kind: kind, verb: verbIndex, indexes: indexes}) +} + +// EncryptInto seals the field into one EQL value of the named type. +func (d Declaration) EncryptInto(name string, kind Kind, eqlType string) Declaration { + return d.add(field{name: name, kind: kind, verb: verbEncryptInto, eqlType: eqlType}) +} + +// Omit leaves the field out: it does not cross the binding and is not +// stored. The declaration lists it so a reader sees the choice. +func (d Declaration) Omit(name string) Declaration { + return d.add(field{name: name, verb: verbOmit}) +} + +// Identity gives the named field a context part other than its name: a +// column that was renamed keeps the identity it was first written under, +// so data written before the change still decrypts. +func (d Declaration) Identity(name, identity string) Declaration { + if d.err != nil { + return d + } + for i := range d.fields { + if d.fields[i].name == name { + // A copy, so the receiver's fields are not changed under it. + d.fields = slices.Clone(d.fields) + d.fields[i].identity = identity + return d + } + } + d.err = fmt.Errorf("gensupport: Identity(%q): no such field", name) + return d +} + +func (d Declaration) add(f field) Declaration { + if d.err != nil { + return d + } + if d.opaque && f.name != OpaqueField { + d.err = fmt.Errorf("gensupport: an opaque declaration has one field, not %q", f.name) + return d + } + if f.name == "" { + d.err = errors.New("gensupport: a field has no name") + return d + } + for _, prior := range d.fields { + if prior.name == f.name { + d.err = fmt.Errorf("gensupport: field %q is declared twice", f.name) + return d + } + } + if (f.verb == verbEncryptIndex || f.verb == verbIndex) && len(f.indexes) == 0 { + d.err = fmt.Errorf("gensupport: field %q: an indexed field names at least one index", f.name) + return d + } + // Clip, so the append copies and never writes into an array that an + // earlier Declaration shares. + d.fields = append(slices.Clip(d.fields), f) + return d +} + +// Err is the declaration's mistake, if any. +func (d Declaration) Err() error { return d.err } + +// sealed reports whether a field has a ciphertext or a term. +func (f field) sealed() bool { + switch f.verb { + case verbEncrypt, verbEncryptIndex, verbIndex, verbEncryptInto: + return true + } + return false +} + +// crosses reports whether a field crosses the binding: a sealed field, and +// the context field, which the engine reads. +func (f field) crosses() bool { + return f.sealed() || f.verb == verbContextField +} + +// plan lowers the declaration to what the engine reads: the sealed fields, +// each with its label, outputs and type. +func (d Declaration) plan() (*record.Plan, error) { + if d.err != nil { + return nil, d.err + } + p := &record.Plan{Context: d.context, ContextField: d.contextField} + for _, f := range d.fields { + if !f.sealed() { + continue + } + rf := record.Field{Name: f.name, Identity: f.identity, Kind: record.Kind(f.kind)} + switch f.verb { + case verbEncryptInto: + // The next build of the engine lists its EQL types through + // se_targets; until then no declaration can seal into one. + return nil, fmt.Errorf("gensupport: field %q: EQL types are not available yet", f.name) + case verbEncrypt: + rf.Outputs = []record.Output{record.Ciphertext} + case verbEncryptIndex: + rf.Outputs = []record.Output{record.Ciphertext} + } + for _, idx := range f.indexes { + out := idx.Output() + if _, ok := termOutputs[out]; !ok { + return nil, fmt.Errorf("gensupport: field %q: the engine does not derive the %s index yet", f.name, idx) + } + rf.Outputs = append(rf.Outputs, out) + } + p.Fields = append(p.Fields, rf) + } + if len(p.Fields) == 0 { + return nil, errors.New("gensupport: the declaration seals no field") + } + if err := p.Validate(); err != nil { + return nil, fmt.Errorf("gensupport: %v", err) + } + return p, nil +} + +// termOutputs are the indexes the engine derives. +var termOutputs = map[record.Output]struct{}{ + record.Equality: {}, record.Match: {}, record.Ore: {}, record.Ope: {}, +} diff --git a/languages/golang/encrypt/gensupport/export_test.go b/languages/golang/encrypt/gensupport/export_test.go new file mode 100644 index 000000000..362e94262 --- /dev/null +++ b/languages/golang/encrypt/gensupport/export_test.go @@ -0,0 +1,20 @@ +package gensupport + +import "io" + +// SetNotices redirects the notices for a test and returns the previous writer. +func SetNotices(w io.Writer) io.Writer { + noticesMu.Lock() + defer noticesMu.Unlock() + prev := notices + notices = w + return prev +} + +// ResetNoticed forgets which notices were printed. +func ResetNoticed() { + noticed.Range(func(k, _ any) bool { + noticed.Delete(k) + return true + }) +} diff --git a/languages/golang/encrypt/gensupport/field.go b/languages/golang/encrypt/gensupport/field.go new file mode 100644 index 000000000..b8081ba23 --- /dev/null +++ b/languages/golang/encrypt/gensupport/field.go @@ -0,0 +1,114 @@ +package gensupport + +import ( + "context" + "fmt" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/internal/record" +) + +// Field is one sealed field's entry in a generated Fields value: it encrypts +// one value of the field, for an update of one column, and derives the +// terms the field declares, for a query. T is the field's Go type. +type Field[T any] struct { + name string + plan *record.Plan + err error +} + +// NewField makes the entry for one sealed field of a declaration. +func NewField[T any](d Declaration, name string) Field[T] { + f := Field[T]{name: name} + plan, err := d.plan() + if err != nil { + f.err = err + return f + } + rf := plan.Field(name) + if rf == nil { + f.err = fmt.Errorf("gensupport: NewField(%q): the declaration seals no such field", name) + return f + } + // One field, its own plan: the label is the same, so the bytes are the + // same as in a whole record. + f.plan = &record.Plan{Context: plan.Context, ContextField: plan.ContextField, Fields: []record.Field{*rf}} + return f +} + +// Encrypt seals one value of the field: its ciphertext and every term it +// declares, in one request. +func (f Field[T]) Encrypt(ctx context.Context, c *encrypt.Cipher, v T) (Output, error) { + if f.err != nil { + return Output{}, f.err + } + if c == nil { + return Output{}, fmt.Errorf("gensupport: %s: Encrypt needs a cipher", f.name) + } + row := record.Source{f.name: v} + if f.plan.ContextField != "" { + // One value of one field has no record to take its context from: + // the cipher names it, as it does for a query. + label := c.ContextLabel() + if label == "" { + return Output{}, fmt.Errorf("%w: %s: the type takes its context from its field %q, so one value is encrypted through a cipher that names it with Cipher.Context", encrypt.ErrEncoding, f.name, f.plan.ContextField) + } + row[f.plan.ContextField] = label + } + sealed, err := c.Seal(ctx, f.plan, []record.Source{row}) + if err != nil { + return Output{}, err + } + if len(sealed) != 1 { + return Output{}, fmt.Errorf("gensupport: %s: one value came back as %d", f.name, len(sealed)) + } + return outputOf(sealed[0][f.name]), nil +} + +// Query derives the EQL query value for one value of an encrypt_into field. +// No EQL type is available in this build of the engine, so it fails. +func (f Field[T]) Query(context.Context, *encrypt.Cipher, T) (Output, error) { + if f.err != nil { + return Output{}, f.err + } + return Output{}, fmt.Errorf("gensupport: %s: EQL types are not available yet", f.name) +} + +// Equality derives the field's equality term for one value. +func (f Field[T]) Equality(ctx context.Context, c *encrypt.Cipher, v T) (encrypt.EqualityTerm, error) { + return f.term(ctx, c, record.Equality, v) +} + +// Match derives the field's match term for one value. +func (f Field[T]) Match(ctx context.Context, c *encrypt.Cipher, v T) (encrypt.MatchTerm, error) { + return f.term(ctx, c, record.Match, v) +} + +// Ore derives the field's ORE term for one value. +func (f Field[T]) Ore(ctx context.Context, c *encrypt.Cipher, v T) (encrypt.OreTerm, error) { + return f.term(ctx, c, record.Ore, v) +} + +// Ope derives the field's OPE term for one value. +func (f Field[T]) Ope(ctx context.Context, c *encrypt.Cipher, v T) (encrypt.OpeTerm, error) { + return f.term(ctx, c, record.Ope, v) +} + +// JSON derives the field's json index term. The engine does not derive it +// yet, so it fails. +func (f Field[T]) JSON(context.Context, *encrypt.Cipher, T) (encrypt.JSONTerm, error) { + if f.err != nil { + return nil, f.err + } + return nil, fmt.Errorf("gensupport: %s: the engine does not derive the json index yet", f.name) +} + +func (f Field[T]) term(ctx context.Context, c *encrypt.Cipher, output record.Output, v T) ([]byte, error) { + if f.err != nil { + return nil, f.err + } + if c == nil { + return nil, fmt.Errorf("gensupport: %s: a term needs a cipher", f.name) + } + return c.Derive(ctx, f.plan, f.name, output, v) +} diff --git a/languages/golang/encrypt/gensupport/gensupport.go b/languages/golang/encrypt/gensupport/gensupport.go new file mode 100644 index 000000000..27bf21fb8 --- /dev/null +++ b/languages/golang/encrypt/gensupport/gensupport.go @@ -0,0 +1,140 @@ +// Package gensupport holds what only code written by stashgen calls. +// +// A program never imports this package: it calls the functions the generated +// file writes into its own package (users.Encrypt, users.Decrypt, +// users.Fields). The generated file names [GeneratedVersion1], builds a +// [Declaration] from the struct's tags, hands the library its conversions in +// a [Generated] value, and prints through [Redacted] and [RedactedLog]. The +// library lowers the declaration to the data plan the engine reads, sends a +// slice of values in one guest call, and reports the two notices that the +// generator also prints. No function in this package panics. +package gensupport + +import ( + "fmt" + "io" + "log/slog" + "os" + "sort" + "strings" + "sync" +) + +// generatedVersion is the type of the version constants. Only a library +// version that accepts a generated file's layout declares the constant that +// file names, so a file from another version does not compile. +type generatedVersion uint8 + +// GeneratedVersion1 is the layout of files that stashgen writes today. Every +// generated file holds `const _ = gensupport.GeneratedVersion1`. +const GeneratedVersion1 generatedVersion = 1 + +// sealed is what a hidden field prints as. +const sealed = "[sealed]" + +// Redacted formats a value for String. The shown fields print with their +// values, in name order; the hidden fields print as [sealed]. Generated code +// passes the passthrough fields as shown and the sealed fields as hidden, so +// no plaintext reaches the output. +func Redacted(typeName string, shown map[string]any, hidden ...string) string { + var b strings.Builder + b.WriteString(typeName) + b.WriteByte('{') + first := true + for _, name := range sortedKeys(shown) { + if !first { + b.WriteString(", ") + } + first = false + fmt.Fprintf(&b, "%s: %v", name, shown[name]) + } + for _, name := range hidden { + if !first { + b.WriteString(", ") + } + first = false + b.WriteString(name) + b.WriteString(": ") + b.WriteString(sealed) + } + b.WriteByte('}') + return b.String() +} + +// RedactedLog is [Redacted] for slog: a group with one attribute for each +// shown field, in name order, and the string [sealed] for each hidden field. +func RedactedLog(shown map[string]any, hidden ...string) slog.Value { + attrs := make([]slog.Attr, 0, len(shown)+len(hidden)) + for _, name := range sortedKeys(shown) { + attrs = append(attrs, slog.Any(name, shown[name])) + } + for _, name := range hidden { + attrs = append(attrs, slog.String(name, sealed)) + } + return slog.GroupValue(attrs...) +} + +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// notices is where the running program's notices go. Tests replace it. +var ( + noticesMu sync.Mutex + notices io.Writer = os.Stderr + noticed sync.Map // notice key -> struct{} +) + +// NoticeUntagged prints, once for each type, that the unexported fields are +// neither encrypted nor stored. stashgen printed the same notice when it +// wrote the file, and the file names the fields in a comment. The tag +// `stash:"-"` on each field stops all three. +func NoticeUntagged(typeName string, fields []string) { + if len(fields) == 0 { + return + } + notice("untagged:"+typeName, fmt.Sprintf( + "stashgen: %s: not encrypted and not stored: the unexported %s. Tag %s `stash:\"-\"` to confirm that.", + typeName, fieldList(fields), itOrEach(fields))) +} + +// NoticePrintsPlaintext prints, once for each type, that the type prints its +// sealed fields in the clear because it has no String and LogValue methods. +// stashgen printed the same notice when it wrote the file. +func NoticePrintsPlaintext(typeName string) { + notice("prints:"+typeName, fmt.Sprintf( + "stashgen: %s prints its sealed fields in the clear: it has no String or LogValue method. Write them, or run stashgen with -redact.", + typeName)) +} + +func notice(key, text string) { + if _, seen := noticed.LoadOrStore(key, struct{}{}); seen { + return + } + noticesMu.Lock() + defer noticesMu.Unlock() + fmt.Fprintln(notices, text) +} + +func fieldList(fields []string) string { + quoted := make([]string, len(fields)) + for i, f := range fields { + quoted[i] = fmt.Sprintf("%q", f) + } + if len(quoted) == 1 { + return "field " + quoted[0] + } + return "fields " + strings.Join(quoted[:len(quoted)-1], ", ") + " and " + quoted[len(quoted)-1] +} + +func itOrEach(fields []string) string { + if len(fields) == 1 { + return "it" + } + return "each" +} diff --git a/languages/golang/encrypt/gensupport/gensupport_internal_test.go b/languages/golang/encrypt/gensupport/gensupport_internal_test.go new file mode 100644 index 000000000..059348b92 --- /dev/null +++ b/languages/golang/encrypt/gensupport/gensupport_internal_test.go @@ -0,0 +1,389 @@ +package gensupport + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "reflect" + "strings" + "testing" + "time" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/internal/record" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +func TestDeclarationLowersToTheEnginesPlan(t *testing.T) { + d := Declare("users"). + Passthrough("id"). + EncryptIndex("age", Uint32, encrypt.Equality, encrypt.Ore). + EncryptIndex("email", String, encrypt.Equality, encrypt.Match()). + Encrypt("notes", String). + Index("score", Int64, encrypt.Ope). + Omit("internal") + plan, err := d.plan() + if err != nil { + t.Fatal(err) + } + want := &record.Plan{Context: []string{"users"}, Fields: []record.Field{ + {Name: "age", Kind: record.Uint32, Outputs: []record.Output{record.Ciphertext, record.Equality, record.Ore}}, + {Name: "email", Kind: record.String, Outputs: []record.Output{record.Ciphertext, record.Equality, record.Match}}, + {Name: "notes", Kind: record.String, Outputs: []record.Output{record.Ciphertext}}, + {Name: "score", Kind: record.Int64, Outputs: []record.Output{record.Ope}}, + }} + if !reflect.DeepEqual(plan, want) { + t.Fatalf("plan = %+v", plan) + } + // Passthrough and omitted fields stay on the host; the engine never + // hears of them. + if plan.Field("id") != nil || plan.Field("internal") != nil { + t.Fatal("a passthrough or omitted field reached the plan") + } + // An opaque declaration is one untyped field. + op, err := DeclareOpaque("documents/v2/body").plan() + if err != nil { + t.Fatal(err) + } + if len(op.Fields) != 1 || op.Fields[0].Name != OpaqueField || op.Fields[0].Kind != record.Bytes || op.Descriptor(op.Fields[0]) != "documents/v2/body/value" { + t.Fatalf("opaque plan = %+v", op) + } + // Identity pins the context part. + id, err := Declare("individuals").Encrypt("medicare_number", String).Identity("medicare_number", "medicare_no").plan() + if err != nil { + t.Fatal(err) + } + if id.Descriptor(id.Fields[0]) != "individuals/medicare_no" { + t.Fatalf("identity: %q", id.Descriptor(id.Fields[0])) + } +} + +func TestDeclarationRefusals(t *testing.T) { + cases := map[string]Declaration{ + "empty context": Declare(""), + "context not a label": Declare("users/1x"), + "field twice": Declare("u").Encrypt("a", String).Encrypt("a", String), + "no name": Declare("u").Encrypt("", String), + "indexed with no index": Declare("u").EncryptIndex("a", String), + "opaque with another field": DeclareOpaque("u").Encrypt("a", String), + "identity of no field": Declare("u").Encrypt("a", String).Identity("b", "x"), + "seals nothing": Declare("u").Passthrough("id"), + "EQL not available": Declare("u").EncryptInto("email", String, "TextEq"), + "json index": Declare("u").EncryptIndex("a", String, encrypt.JSON()), + "name not a label": Declare("u").Encrypt("1a", String), + } + for name, d := range cases { + if _, err := d.plan(); err == nil { + t.Errorf("%s: accepted", name) + } + } + if _, err := Declare("u").EncryptInto("email", String, "TextEq").plan(); err == nil || !strings.Contains(err.Error(), "EQL types are not available yet") { + t.Fatalf("encrypt_into: %v", err) + } +} + +func TestConvertStaysWithinAFamily(t *testing.T) { + var u8 uint8 + if err := convert(uint32(7), &u8); err != nil || u8 != 7 { + t.Fatalf("uint32 -> uint8: %v %d", err, u8) + } + if err := convert(uint32(300), &u8); err == nil { + t.Fatal("300 fit a uint8") + } + var i int + if err := convert(int64(-5), &i); err != nil || i != -5 { + t.Fatalf("int64 -> int: %v %d", err, i) + } + if err := convert(uint64(1<<63), &i); err == nil { + t.Fatal("2^63 fit an int") + } + var u uint + if err := convert(int32(-1), &u); err == nil { + t.Fatal("-1 fit a uint") + } + var s string + if err := convert(int32(1), &s); err == nil { + t.Fatal("an integer became a string") + } + var f32 float32 + if err := convert(float64(1.5), &f32); err != nil || f32 != 1.5 { + t.Fatalf("float64 -> float32: %v %v", err, f32) + } + var b []byte + src := []byte{1, 2} + if err := convert(src, &b); err != nil { + t.Fatal(err) + } + src[0] = 9 + if b[0] != 1 { + t.Fatal("convert aliased the decoded slice") + } + var tags []string + if err := convert([]any{"a", "b"}, &tags); err != nil || !reflect.DeepEqual(tags, []string{"a", "b"}) { + t.Fatalf("[]string: %v %v", err, tags) + } + if err := convert([]any{"a", 1}, &tags); err == nil { + t.Fatal("a mixed array became []string") + } + var vals Values + obj := vcvalue.Object{{Key: "title", Value: "x"}, {Key: "inner", Value: vcvalue.Object{{Key: "n", Value: int64(1)}}}} + if err := convert(obj, &vals); err != nil { + t.Fatal(err) + } + if vals["title"] != "x" || vals["inner"].(Values)["n"] != int64(1) { + t.Fatalf("Values = %#v", vals) + } + type unsupported struct{ A int } + var x unsupported + if err := convert(obj, &x); err == nil { + t.Fatal("a struct target was accepted") + } + // An opaque value decodes straight into the generated shape type. + type shape struct { + Title string `json:"title"` + N int32 `json:"n"` + Tags []string `json:"tags"` + Inner map[string]string `json:"inner"` + } + encoded, err := opaqueBytes(shape{Title: "x", N: 4, Tags: []string{"a"}, Inner: map[string]string{"k": "v"}}) + if err != nil { + t.Fatal(err) + } + var back shape + if err := Opaque(Values{OpaqueField: encoded}, &back); err != nil || back.Title != "x" || back.N != 4 || back.Inner["k"] != "v" { + t.Fatalf("Opaque: %v %+v", err, back) + } + if err := Opaque(Values{OpaqueField: "not bytes"}, &back); err == nil { + t.Fatal("Opaque accepted a string") + } + if err := Opaque(Values{}, &back); err == nil { + t.Fatal("Opaque found a missing field") + } + // A passthrough value of any type comes back as it is. + when := time.Date(2026, 10, 6, 1, 2, 3, 0, time.UTC) + gotTime, err := Get[time.Time](Values{"at": when}, "at") + if err != nil || !gotTime.Equal(when) { + t.Fatalf("Get[time.Time] = %v %v", gotTime, err) + } + type defined string + gotDefined, err := Get[defined](Values{"d": defined("x")}, "d") + if err != nil || gotDefined != "x" { + t.Fatalf("Get[defined] = %v %v", gotDefined, err) + } + // A defined type is read at its underlying type when the value is the + // engine's wire value. + if got, err := Get[defined](Values{"d": "x"}, "d"); err != nil || got != "x" { + t.Fatalf("Get[defined] from the wire = %v %v", got, err) + } + got, err := Get[uint8](Values{"age": uint32(3)}, "age") + if err != nil || got != 3 { + t.Fatalf("Get = %v %v", got, err) + } + if _, err := Get[uint8](Values{}, "age"); err == nil { + t.Fatal("Get found a missing field") + } + p, err := Passthrough[int64](Record{"id": {Value: int64(4)}}, "id") + if err != nil || p != 4 { + t.Fatalf("Passthrough = %v %v", p, err) + } + if _, err := Passthrough[int64](Record{"id": {Value: "4"}}, "id"); err == nil { + t.Fatal("Passthrough converted a string") + } +} + +type user struct { + ID int64 + Email string + Note string +} + +type encryptedUser struct { + ID int64 + Email Output + Note encrypt.Ciphertext +} + +func userCodec() *Codec[user, encryptedUser] { + return New(Generated[user, encryptedUser]{ + TypeName: "User", + Declaration: Declare("users").Passthrough("id").EncryptIndex("email", String, encrypt.Equality).Encrypt("note", String).Omit("internal"), + Source: func(u user) Values { return Values{"id": u.ID, "email": u.Email, "note": u.Note} }, + Seal: func(rec Record) (encryptedUser, error) { + id, err := Passthrough[int64](rec, "id") + return encryptedUser{ID: id, Email: rec["email"], Note: rec["note"].Ciphertext}, err + }, + Open: func(e encryptedUser) Record { + return Record{"id": {Value: e.ID}, "email": {Ciphertext: e.Email.Ciphertext}, "note": {Ciphertext: e.Note}} + }, + Value: func(e encryptedUser, vals Values) (user, error) { + var u user + var err error + if u.ID, err = Get[int64](vals, "id"); err != nil { + return user{}, err + } + if u.Email, err = Get[string](vals, "email"); err != nil { + return user{}, err + } + if u.Note, err = Get[string](vals, "note"); err != nil { + return user{}, err + } + return u, nil + }, + }) +} + +// A value that does not fit its Go type is decrypted plaintext: the error +// names the types and wraps ErrEncoding, and never holds the value. +func TestDecryptErrorsHoldNoPlaintext(t *testing.T) { + cases := []struct { + name string + value any + read func(Values) error + leak string + }{ + {"uint8 range", uint32(300), func(v Values) error { _, err := Get[uint8](v, "f"); return err }, "300"}, + {"int range", uint64(1<<63 + 4242), func(v Values) error { _, err := Get[int](v, "f"); return err }, "4242"}, + {"negative uint", int64(-4242), func(v Values) error { _, err := Get[uint](v, "f"); return err }, "4242"}, + {"float32 range", float64(4.242e300), func(v Values) error { _, err := Get[float32](v, "f"); return err }, "4.242"}, + {"json int", json.Number("4242.5"), func(v Values) error { _, err := Get[int64](v, "f"); return err }, "4242"}, + {"json uint", json.Number("-4242"), func(v Values) error { _, err := Get[uint64](v, "f"); return err }, "4242"}, + {"json float", json.Number("4242e999"), func(v Values) error { _, err := Get[float64](v, "f"); return err }, "4242"}, + {"map key", Values{"secret-key-4242": "x"}, func(v Values) error { _, err := Get[map[string]int](v, "f"); return err }, "4242"}, + {"opaque bytes", []byte(`{"n":4242}`), func(v Values) error { + var out struct{ N uint8 } + return Opaque(Values{OpaqueField: v["f"]}, &out) + }, "4242"}, + } + for _, tc := range cases { + err := tc.read(Values{"f": tc.value}) + if err == nil { + t.Fatalf("%s: no error", tc.name) + } + if !errors.Is(err, encrypt.ErrEncoding) { + t.Errorf("%s: %v does not wrap ErrEncoding", tc.name, err) + } + if strings.Contains(err.Error(), tc.leak) { + t.Errorf("%s: the error holds the value: %v", tc.name, err) + } + } + // encoding/json's own error ("json: unsupported value: NaN") quotes the + // value, so it is not wrapped. + if _, err := opaqueBytes(struct{ F float64 }{math.NaN()}); !errors.Is(err, encrypt.ErrEncoding) || strings.Contains(err.Error(), "json:") { + t.Errorf("opaqueBytes NaN: %v", err) + } +} + +func TestSplitFailsClosedInBothDirections(t *testing.T) { + c := userCodec() + if c.err != nil { + t.Fatal(c.err) + } + row, keep, err := c.split(Values{"id": int64(1), "email": "a", "note": "b"}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(row, record.Source{"email": "a", "note": "b"}) || !reflect.DeepEqual(keep, map[string]any{"id": int64(1)}) { + t.Fatalf("split = %v %v", row, keep) + } + if _, _, err := c.split(Values{"id": int64(1), "email": "a"}); err == nil || !strings.Contains(err.Error(), `no field "note"`) { + t.Fatalf("missing field: %v", err) + } + if _, _, err := c.split(Values{"id": int64(1), "email": "a", "note": "b", "stray": 1}); err == nil || !strings.Contains(err.Error(), `"stray"`) { + t.Fatalf("extra field: %v", err) + } +} + +func TestNewReportsAnIncompleteFileAndABadDeclarationOnFirstUse(t *testing.T) { + c := New(Generated[user, encryptedUser]{TypeName: "User", Declaration: Declare("users").Encrypt("a", String)}) + if _, err := c.Encrypt(t.Context(), nil, nil); err == nil || !strings.Contains(err.Error(), "incomplete") { + t.Fatalf("incomplete: %v", err) + } + bad := userCodec() + bad.g.Declaration = Declare("") + bad.plan, bad.err = bad.g.Declaration.plan() + if _, err := bad.Decrypt(t.Context(), nil, nil); err == nil { + t.Fatal("a bad declaration was not reported") + } + // No cipher: a programming error, reported, not a nil dereference. + if _, err := userCodec().Encrypt(t.Context(), nil, []user{{}}); err == nil { + t.Fatal("a nil cipher was accepted") + } + if _, err := userCodec().Decrypt(t.Context(), nil, []encryptedUser{{}}); err == nil { + t.Fatal("a nil decrypter was accepted") + } +} + +type status string + +// Every type the generator accepts on a sealed field, and every type JSON +// hands back for an opaque one, reads through Get. The reviewer's cases. +func TestGetReadsEveryTypeTheGeneratorAccepts(t *testing.T) { + type labels map[string]string + cases := map[string]func() error{ + "named string": func() error { v, err := Get[status](Values{"v": "active"}, "v"); return check(err, v == "active") }, + "named int64": func() error { v, err := Get[time.Duration](Values{"v": int64(5)}, "v"); return check(err, v == 5) }, + "named int16": func() error { v, err := Get[Score](Values{"v": int32(-3)}, "v"); return check(err, v == -3) }, + "named []byte": func() error { v, err := Get[Blob](Values{"v": []byte{1}}, "v"); return check(err, len(v) == 1) }, + "[]int": func() error { + v, err := Get[[]int](Values{"v": []any{json.Number("1")}}, "v") + return check(err, len(v) == 1 && v[0] == 1) + }, + "[]int from wire": func() error { v, err := Get[[]int](Values{"v": []any{int64(2)}}, "v"); return check(err, v[0] == 2) }, + "[]float32": func() error { + v, err := Get[[]float32](Values{"v": []any{json.Number("1.5")}}, "v") + return check(err, v[0] == 1.5) + }, + "[]status": func() error { v, err := Get[[]status](Values{"v": []any{"a"}}, "v"); return check(err, v[0] == "a") }, + "[2]uint8": func() error { + v, err := Get[[2]uint8](Values{"v": []any{json.Number("9"), json.Number("8")}}, "v") + return check(err, v == [2]uint8{9, 8}) + }, + "map[string]string": func() error { + v, err := Get[map[string]string](Values{"v": map[string]any{"a": "b"}}, "v") + return check(err, v["a"] == "b") + }, + "named map": func() error { + v, err := Get[labels](Values{"v": map[string]any{"a": "b"}}, "v") + return check(err, v["a"] == "b") + }, + "map[string]int": func() error { + v, err := Get[map[string]int](Values{"v": vcvalue.Object{{Key: "a", Value: int64(3)}}}, "v") + return check(err, v["a"] == 3) + }, + "exact passthrough": func() error { + v, err := Get[time.Time](Values{"v": time.Unix(1, 0)}, "v") + return check(err, v.Unix() == 1) + }, + } + for name, get := range cases { + if err := get(); err != nil { + t.Errorf("%s: %v", name, err) + } + } + // And the refusals stay refusals. + if _, err := Get[status](Values{"v": int64(1)}, "v"); err == nil { + t.Error("an integer became a named string") + } + if _, err := Get[[]int8](Values{"v": []any{int64(300)}}, "v"); err == nil { + t.Error("300 fit an int8 element") + } + if _, err := Get[map[int]string](Values{"v": map[string]any{"a": "b"}}, "v"); err == nil { + t.Error("a map with integer keys was read from a wire object") + } +} + +type ( + Score int16 + Blob []byte +) + +func check(err error, ok bool) error { + if err != nil { + return err + } + if !ok { + return fmt.Errorf("wrong value") + } + return nil +} diff --git a/languages/golang/encrypt/gensupport/gensupport_test.go b/languages/golang/encrypt/gensupport/gensupport_test.go new file mode 100644 index 000000000..68bd64ea0 --- /dev/null +++ b/languages/golang/encrypt/gensupport/gensupport_test.go @@ -0,0 +1,75 @@ +package gensupport + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestRedactedHidesSealedFieldsAndSortsShown(t *testing.T) { + got := Redacted("EncryptedUser", map[string]any{"Nickname": "nick", "ID": int64(7)}, "Email", "Name") + want := "EncryptedUser{ID: 7, Nickname: nick, Email: [sealed], Name: [sealed]}" + if got != want { + t.Fatalf("Redacted = %q, want %q", got, want) + } + if got := Redacted("EncryptedDocument", nil, "Sealed"); got != "EncryptedDocument{Sealed: [sealed]}" { + t.Fatalf("Redacted with no shown fields = %q", got) + } +} + +func TestRedactedLogHidesSealedFields(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{ReplaceAttr: dropTime})) + logger.Info("saved", "user", RedactedLog(map[string]any{"ID": 7}, "Email")) + line := buf.String() + if !strings.Contains(line, "user.ID=7") || !strings.Contains(line, "user.Email=[sealed]") { + t.Fatalf("log line = %q", line) + } +} + +func dropTime(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + return slog.Attr{} + } + return a +} + +func TestNoticesPrintOnceForEachType(t *testing.T) { + var buf bytes.Buffer + prev := SetNotices(&buf) + defer SetNotices(prev) + ResetNoticed() + + NoticeUntagged("Account", []string{"cache"}) + NoticeUntagged("Account", []string{"cache"}) + NoticeUntagged("Other", []string{"a", "b"}) + NoticeUntagged("Empty", nil) + NoticePrintsPlaintext("User") + NoticePrintsPlaintext("User") + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + if len(lines) != 3 { + t.Fatalf("got %d notices, want 3:\n%s", len(lines), buf.String()) + } + if want := `stashgen: Account: not encrypted and not stored: the unexported field "cache". Tag it ` + "`stash:\"-\"`" + ` to confirm that.`; lines[0] != want { + t.Fatalf("line 0 = %q\nwant %q", lines[0], want) + } + if !strings.Contains(lines[1], `fields "a" and "b". Tag each`) { + t.Fatalf("line 1 = %q", lines[1]) + } + if !strings.Contains(lines[2], "User prints its sealed fields in the clear") { + t.Fatalf("line 2 = %q", lines[2]) + } +} + +func TestVersionConstantIsTyped(t *testing.T) { + // A generated file holds `const _ = gensupport.GeneratedVersion1`. The + // constant's type is unexported, so no other package can declare a value + // that satisfies the same reference. + accepts := func(generatedVersion) {} + accepts(GeneratedVersion1) + if GeneratedVersion1 != 1 { + t.Fatalf("GeneratedVersion1 = %d", GeneratedVersion1) + } +} diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/encrypt/guest.go similarity index 87% rename from languages/golang/stackencrypt/guest.go rename to languages/golang/encrypt/guest.go index 7c521331f..59096fb9f 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/encrypt/guest.go @@ -1,4 +1,4 @@ -package stackencrypt +package encrypt import ( "context" @@ -26,8 +26,8 @@ var guestFS embed.FS const guestPath = "wasm/stack_encrypt_guest.wasm" // ErrGuestNotBuilt is returned by NewClient when no guest module is -// embedded and none was supplied with WithGuest. -var ErrGuestNotBuilt = errors.New("stackencrypt: guest module not built — run `mise run wasm:guest:build`") +// embedded. +var ErrGuestNotBuilt = errors.New("encrypt: guest module not built — run `mise run wasm:guest:build`") func embeddedGuest() ([]byte, error) { wasm, err := guestFS.ReadFile(guestPath) @@ -61,10 +61,9 @@ type instance struct { exports guest.Exports cipherInit, shutdown, keyset api.Function - encrypt, encryptElement api.Function - decrypt, decryptElement api.Function term api.Function encryptRecord, decryptRecord api.Function + planCheck, targets api.Function } // guestModuleConfig is the module configuration every guest instance runs @@ -106,7 +105,7 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy guest.Lo // call that does not currently fail, matching the host transport below. if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { _ = runtime.Close(ctx) - return nil, fmt.Errorf("stackencrypt: instantiating WASI: %w", err) + return nil, fmt.Errorf("encrypt: instantiating WASI: %w", err) } if err := t.instantiate(ctx, runtime); err != nil { _ = runtime.Close(ctx) @@ -133,7 +132,7 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy guest.Lo if g := mem.GrowthRefusal(); g.Refused != 0 { return nil, fmt.Errorf("%w: %w", guest.MemoryLockError(g.Reason), err) } - return nil, fmt.Errorf("stackencrypt: instantiating guest: %w", err) + return nil, fmt.Errorf("encrypt: instantiating guest: %w", err) } if policy == guest.Strict { if lerr := mem.LockError(); lerr != nil { @@ -143,23 +142,21 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy guest.Lo } inst := &instance{runtime: runtime, module: module, mem: mem} exports := map[string]*api.Function{ - "se_alloc": &inst.exports.Alloc, - "se_dealloc": &inst.exports.Dealloc, - "se_cipher_init": &inst.cipherInit, - "se_shutdown": &inst.shutdown, - "se_keyset": &inst.keyset, - "se_encrypt": &inst.encrypt, - "se_encrypt_element": &inst.encryptElement, - "se_decrypt": &inst.decrypt, - "se_decrypt_element": &inst.decryptElement, - "se_term": &inst.term, - "se_encrypt_record": &inst.encryptRecord, - "se_decrypt_record": &inst.decryptRecord, + "se_alloc": &inst.exports.Alloc, + "se_dealloc": &inst.exports.Dealloc, + "se_cipher_init": &inst.cipherInit, + "se_shutdown": &inst.shutdown, + "se_keyset": &inst.keyset, + "se_term": &inst.term, + "se_encrypt_record": &inst.encryptRecord, + "se_decrypt_record": &inst.decryptRecord, + "se_plan_check": &inst.planCheck, + "se_targets": &inst.targets, } for name, slot := range exports { if *slot = module.ExportedFunction(name); *slot == nil { _ = runtime.Close(ctx) - return nil, fmt.Errorf("stackencrypt: guest is missing export %s", name) + return nil, fmt.Errorf("encrypt: guest is missing export %s", name) } } return inst, nil diff --git a/languages/golang/stackencrypt/guest/.gitignore b/languages/golang/encrypt/guest/.gitignore similarity index 100% rename from languages/golang/stackencrypt/guest/.gitignore rename to languages/golang/encrypt/guest/.gitignore diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock similarity index 100% rename from languages/golang/stackencrypt/guest/Cargo.lock rename to languages/golang/encrypt/guest/Cargo.lock diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/encrypt/guest/Cargo.toml similarity index 84% rename from languages/golang/stackencrypt/guest/Cargo.toml rename to languages/golang/encrypt/guest/Cargo.toml index e676076a6..3d47e469d 100644 --- a/languages/golang/stackencrypt/guest/Cargo.toml +++ b/languages/golang/encrypt/guest/Cargo.toml @@ -22,6 +22,16 @@ publish = false # unit-test natively (`cargo test` here, no wasm toolchain needed). crate-type = ["cdylib", "rlib"] +[features] +# A TEST BUILD of the guest whose key source is the deterministic one the +# record fixture (packages/stack-encrypt/tests/fixtures/record_lowering.json) +# was sealed under: `se_cipher_init` takes the 32-byte seed and nothing +# else, and every data key derives from it. No ZeroKMS, no token, no +# network. The Go tests load it to open the fixture's records and to run +# hermetic round trips; `mise run wasm:guest:build:deterministic` builds it +# beside the real guest. Never the embedded guest. +deterministic-kms = ["stack-kms/test-support"] + [dependencies] # The stack crates with default features off: no reqwest, no native TLS — # HTTP comes from the host (see `wasm:wasi-check` in the suite root). diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/encrypt/guest/src/abi.rs similarity index 70% rename from languages/golang/stackencrypt/guest/src/abi.rs rename to languages/golang/encrypt/guest/src/abi.rs index 74ffeb297..163c81e3c 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/encrypt/guest/src/abi.rs @@ -6,9 +6,8 @@ //! //! - **Every export handed plaintext wipes that buffer in place before it //! returns**, rather than leaving it for `se_dealloc`: [`se_cipher_init`] -//! (the config carries the client key), and [`se_encrypt`], -//! [`se_encrypt_element`], [`se_term`] and [`se_encrypt_record`] (their -//! value/source buffers). The host's plaintext therefore lives no longer +//! (the config carries the client key), and [`se_term`] and +//! [`se_encrypt_record`] (their value/source buffers). The host's plaintext therefore lives no longer //! than the call, instead of until the host gets round to releasing it. //! **A host must not read a plaintext input buffer back after the call, or //! pass the same buffer to two calls** — it will be zeros. Option, context, @@ -41,14 +40,13 @@ //! / token); calling any other export from inside a host import is //! undefined behaviour of the embedding, not of this module. //! -//! The value exports ([`se_encrypt`] and friends) are the cipher-directed -//! path and take the AAD as `KeysetCipher::encrypt` does: any bytes, none -//! included — a null pointer with zero length is the empty AAD, as a Go -//! `nil` slice is. The record and term exports bind fields, so their -//! contexts must be non-empty (`STATUS_ENCODING` otherwise): each is a -//! [`stack_encrypt::NonEmpty`] from the moment it is parsed, and the sealing -//! and opening sides bind that one value. The asymmetry is the design; see -//! `packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md`. +//! There are no whole-value exports: every value crosses as a record under +//! a declaration (ADR-0007, amended). The record and term exports bind +//! fields, so their contexts must be non-empty (`STATUS_ENCODING` +//! otherwise): each is a [`stack_encrypt::NonEmpty`] from the moment it is +//! parsed, and the sealing and opening sides bind that one value. +//! [`se_plan_check`] and [`se_targets`] answer the Go generator's questions +//! and need no cipher. //! //! Every export decodes and validates *all* of its inputs — the operation //! payload, the plan or context, the term kind, the value against the @@ -70,20 +68,25 @@ use futures::executor::block_on; use stack_encrypt::{KeysetCipher, StackCipher}; use stack_guest_abi::abi::{err_status, input, ok_buffer, take_plaintext, wipe_input}; use stack_guest_abi::buffers; -use stack_kms::{ClientOpts, StackKms}; use vitaminc_aead_value::transport as codec; use vitaminc_aead_value::FfiValue; -use crate::config::parse_config; -use crate::host::{HostTokenStrategy, WasiHostConnection}; use crate::ops; use crate::options::{parse_options, parse_selector, scope_for, KeysetSelector, Side}; -use crate::status::{STATUS_ENCODING, STATUS_INTERNAL, STATUS_KMS_TRANSPORT, STATUS_STATE}; +use crate::status::{STATUS_ENCODING, STATUS_INTERNAL, STATUS_STATE}; use stack_encrypt::dynamic::Scope; -/// The instance's cipher: `stack-encrypt` over the host-transport ZeroKMS -/// client with host-supplied tokens. -type GuestCipher = StackCipher>; +/// The key source the instance's cipher runs over: the host-transport +/// ZeroKMS client with host-supplied tokens — or, in the `deterministic-kms` +/// test build, the seeded source the record fixture was sealed under. +#[cfg(not(feature = "deterministic-kms"))] +type GuestKms = + stack_kms::StackKms; +#[cfg(feature = "deterministic-kms")] +type GuestKms = crate::deterministic::DeterministicSource; + +/// The instance's cipher: `stack-encrypt` over [`GuestKms`]. +type GuestCipher = StackCipher; thread_local! { // Wasm is single-threaded, so a thread-local `RefCell` is a plain owner @@ -116,7 +119,7 @@ fn with_cipher(f: impl FnOnce(&GuestCipher) -> Result) -> Result( opts: &[u8], - f: impl FnOnce(&KeysetCipher<'_, StackKms>) -> Result, + f: impl FnOnce(&KeysetCipher<'_, GuestKms>) -> Result, ) -> Result { let options = parse_options(decode(opts)?, Side::Mint)?; with_cipher(|cipher| { @@ -129,7 +132,7 @@ fn with_keyset( /// client for `{"any"}`, one keyset's cipher otherwise. fn with_scope( opts: &[u8], - f: impl FnOnce(Scope<'_, StackKms>) -> Result, + f: impl FnOnce(Scope<'_, GuestKms>) -> Result, ) -> Result { let options = parse_options(decode(opts)?, Side::Open)?; with_cipher(|cipher| { @@ -177,8 +180,13 @@ pub unsafe extern "C" fn se_cipher_init(cfg_ptr: *mut u8, cfg_len: u32) -> u64 { .map_or_else(err_status, ok_buffer) } +#[cfg(not(feature = "deterministic-kms"))] fn cipher_init(decoded: FfiValue) -> Result, u32> { - let config = parse_config(decoded).map_err(|_| STATUS_ENCODING)?; + use crate::host::{HostTokenStrategy, WasiHostConnection}; + use crate::status::STATUS_KMS_TRANSPORT; + use stack_kms::{ClientOpts, StackKms}; + + let config = crate::config::parse_config(decoded).map_err(|_| STATUS_ENCODING)?; if SHUT_DOWN.with(Cell::get) || CIPHER.with(|c| c.borrow().is_some()) { return Err(STATUS_STATE); } @@ -207,7 +215,35 @@ fn cipher_init(decoded: FfiValue) -> Result, u32> { if let Some(size) = config.keyset_cache_size { builder = builder.keyset_cache_size(size); } - let cipher = block_on(builder.init()).map_err(|e| crate::status::status_for_error(&e))?; + install(block_on(builder.init()).map_err(|e| crate::status::status_for_error(&e))?) +} + +/// The `deterministic-kms` build's init: the config buffer is the +/// deterministic source's 32-byte seed and nothing else — no client key, no +/// endpoint, no token, no network. A test artefact, never shipped; see +/// [`crate::deterministic`]. +#[cfg(feature = "deterministic-kms")] +fn cipher_init(decoded: FfiValue) -> Result, u32> { + use vitaminc_protected::Controlled; + + let FfiValue::Bytes(seed) = decoded else { + return Err(STATUS_ENCODING); + }; + let seed: [u8; 32] = seed + .risky_ref() + .as_slice() + .try_into() + .map_err(|_| STATUS_ENCODING)?; + if SHUT_DOWN.with(Cell::get) || CIPHER.with(|c| c.borrow().is_some()) { + return Err(STATUS_STATE); + } + let builder = StackCipher::builder().kms(crate::deterministic::DeterministicSource::new(seed)); + install(block_on(builder.init()).map_err(|e| crate::status::status_for_error(&e))?) +} + +/// Install the built cipher as the instance's and return its default +/// keyset's id. +fn install(cipher: GuestCipher) -> Result, u32> { let default = cipher.default_keyset().keyset_id().as_bytes().to_vec(); CIPHER.with(|c| *c.borrow_mut() = Some(cipher)); Ok(default) @@ -247,7 +283,7 @@ pub extern "C" fn se_shutdown() { /// /// # Safety /// -/// As for [`se_encrypt`]. +/// As for [`se_term`]. #[no_mangle] pub unsafe extern "C" fn se_keyset(sel_ptr: *const u8, sel_len: u32) -> u64 { catch_unwind(AssertUnwindSafe(|| { @@ -266,153 +302,6 @@ pub unsafe extern "C" fn se_keyset(sel_ptr: *const u8, sel_len: u32) -> u64 { .map_or_else(err_status, ok_buffer) } -/// Encrypt an FFI-codec-encoded value tree under the keyset `opts` selects, -/// binding `aad`; every leaf is sealed from one batched key request, -/// dispatched as one `generate-data-key` call per 500 keyed leaves (see -/// `cipher_init` for where that bound comes from). Output: packed pointer -/// to a codec-encoded ciphertext tree whose leaves are the frozen -/// `SealedValue` byte encoding, each carrying the keyset's id. -/// -/// `aad` may be empty (a null pointer with zero length is empty) — see this -/// module's hostile-input notes. `opts` is the options object -/// (`{"keyset": }`, [`crate::options`]); `{"any"}` is refused here. -/// -/// # Safety -/// -/// Pointer/length pairs should name buffers the host wrote via -/// `se_alloc`; each range is bounds-checked against linear memory (a bad -/// pair returns `STATUS_ENCODING` instead of faulting). -#[no_mangle] -pub unsafe extern "C" fn se_encrypt( - val_ptr: *mut u8, - val_len: u32, - aad_ptr: *const u8, - aad_len: u32, - opt_ptr: *const u8, - opt_len: u32, -) -> u64 { - run_encrypt(val_ptr, val_len, aad_ptr, aad_len, opt_ptr, opt_len, false) -} - -/// Like [`se_encrypt`], but seals the value as a *sequence element* — rows -/// written through this export interchange with rows written by encrypting -/// a whole sequence under the same AAD. -/// -/// # Safety -/// -/// As for [`se_encrypt`]. -#[no_mangle] -pub unsafe extern "C" fn se_encrypt_element( - val_ptr: *mut u8, - val_len: u32, - aad_ptr: *const u8, - aad_len: u32, - opt_ptr: *const u8, - opt_len: u32, -) -> u64 { - run_encrypt(val_ptr, val_len, aad_ptr, aad_len, opt_ptr, opt_len, true) -} - -/// Decrypt a codec-encoded ciphertext tree back into a codec-encoded value -/// tree; one batched key request per keyset the leaves were sealed under, -/// dispatched as one `retrieve-data-key` call per 500 keyed leaves. The -/// output buffer contains **plaintext** — the host must copy it out and -/// immediately release it with `se_dealloc` (which wipes it). -/// -/// `aad` must be the one the ciphertext was sealed under, empty included. -/// `opts` constrains which keyset may be opened: `{"any"}` opens leaves from -/// whichever keyset each was sealed under; `{"name"}`, `{"id"}` and -/// `{"default"}` refuse a leaf from any other keyset as -/// `STATUS_FOREIGN_KEYSET`, before any key is retrieved. -/// -/// # Safety -/// -/// As for [`se_encrypt`]. -#[no_mangle] -pub unsafe extern "C" fn se_decrypt( - ct_ptr: *const u8, - ct_len: u32, - aad_ptr: *const u8, - aad_len: u32, - opt_ptr: *const u8, - opt_len: u32, -) -> u64 { - run_decrypt(ct_ptr, ct_len, aad_ptr, aad_len, opt_ptr, opt_len, false) -} - -/// Like [`se_decrypt`], but opens the ciphertext as a *sequence element* — -/// the read-side counterpart of [`se_encrypt_element`], for one row of a -/// batch-encrypted sequence under the batch's AAD. -/// -/// # Safety -/// -/// As for [`se_encrypt`]. -#[no_mangle] -pub unsafe extern "C" fn se_decrypt_element( - ct_ptr: *const u8, - ct_len: u32, - aad_ptr: *const u8, - aad_len: u32, - opt_ptr: *const u8, - opt_len: u32, -) -> u64 { - run_decrypt(ct_ptr, ct_len, aad_ptr, aad_len, opt_ptr, opt_len, true) -} - -/// [`se_encrypt`] / [`se_encrypt_element`]'s shared drive: validate, select -/// the keyset, block on the op. -fn run_encrypt( - val_ptr: *mut u8, - val_len: u32, - aad_ptr: *const u8, - aad_len: u32, - opt_ptr: *const u8, - opt_len: u32, - as_element: bool, -) -> u64 { - catch_unwind(AssertUnwindSafe(|| { - // Plaintext first: the wipe writes through `&mut`, so nothing else - // may be borrowed from linear memory yet. - let value = unsafe { take_plaintext(val_ptr, val_len)? }; - let value = value.as_slice(); - // SAFETY: host-owned ranges the export was handed; the borrows end - // before it returns and before any wipe of an overlapping range. - let aad = unsafe { input(aad_ptr, aad_len)? }; - let opts = unsafe { input(opt_ptr, opt_len)? }; - ops::validate::value(value)?; - with_keyset(opts, |keyset| { - block_on(ops::encrypt_value(keyset, value, aad, as_element)) - }) - })) - .unwrap_or(Err(STATUS_INTERNAL)) - .map_or_else(err_status, ok_buffer) -} - -/// [`se_decrypt`] / [`se_decrypt_element`]'s shared drive. -fn run_decrypt( - ct_ptr: *const u8, - ct_len: u32, - aad_ptr: *const u8, - aad_len: u32, - opt_ptr: *const u8, - opt_len: u32, - as_element: bool, -) -> u64 { - catch_unwind(AssertUnwindSafe(|| { - // SAFETY: host-owned ranges the export was handed; the borrows end - // before it returns and before any wipe of an overlapping range. - let ciphertext = unsafe { input(ct_ptr, ct_len)? }; - let aad = unsafe { input(aad_ptr, aad_len)? }; - let opts = unsafe { input(opt_ptr, opt_len)? }; - ops::validate::tree(ciphertext)?; - with_scope(opts, |scope| { - block_on(ops::decrypt_value(scope, ciphertext, aad, as_element)) - }) - })) - .unwrap_or(Err(STATUS_INTERNAL)) - .map_or_else(err_status, ok_buffer) -} - /// Derive one index term under the keyset `opts` selects: a codec-encoded /// scalar, a codec-encoded context and a term kind ([`ops::TERM_EQUALITY`] /// etc.); the output is the term's frozen byte encoding. Under the local @@ -432,7 +321,9 @@ fn run_decrypt( /// /// # Safety /// -/// As for [`se_encrypt`]. +/// Pointer/length pairs should name buffers the host wrote via +/// `se_alloc`; each range is bounds-checked against linear memory (a bad +/// pair returns `STATUS_ENCODING` instead of faulting). #[no_mangle] pub unsafe extern "C" fn se_term( val_ptr: *mut u8, @@ -468,7 +359,7 @@ pub unsafe extern "C" fn se_term( /// /// # Safety /// -/// As for [`se_encrypt`]. +/// As for [`se_term`]. #[no_mangle] pub unsafe extern "C" fn se_encrypt_record( src_ptr: *mut u8, @@ -498,12 +389,18 @@ pub unsafe extern "C" fn se_encrypt_record( /// the same plan; only the `"c"` outputs participate. One batched key /// request per keyset the leaves were sealed under, dispatched as one /// `retrieve-data-key` call per 500 keyed leaves. `opts` constrains the -/// keyset as for [`se_decrypt`]. The output buffer contains **plaintext** — -/// same host obligations as [`se_decrypt`]. +/// keyset: `{"any"}` opens leaves from whichever keyset each was sealed +/// under; `{"name"}`, `{"id"}` and `{"default"}` refuse a leaf from any +/// other keyset as `STATUS_FOREIGN_KEYSET`, before any key is retrieved. +/// `opts` may also carry `context`, the context the host expects each +/// record's context field to hold ([`crate::options`]); a record storing +/// another is `STATUS_CONTEXT_MISMATCH`, before any key is retrieved. +/// The output buffer contains **plaintext**: the host copies it out and +/// immediately `se_dealloc`s it (which wipes it). /// /// # Safety /// -/// As for [`se_encrypt`]. +/// As for [`se_term`]. #[no_mangle] pub unsafe extern "C" fn se_decrypt_record( rec_ptr: *const u8, @@ -519,11 +416,42 @@ pub unsafe extern "C" fn se_decrypt_record( let record = unsafe { input(rec_ptr, rec_len)? }; let plan = unsafe { input(plan_ptr, plan_len)? }; let opts = unsafe { input(opt_ptr, opt_len)? }; - ops::validate::record_tree(record, plan)?; + let expected = parse_options(decode(opts)?, Side::Open)?.context; + ops::validate::record_tree(record, plan, expected.as_ref())?; with_scope(opts, |scope| { - block_on(ops::decrypt_record(scope, record, plan)) + block_on(ops::decrypt_record(scope, record, plan, expected)) }) })) .unwrap_or(Err(STATUS_INTERNAL)) .map_or_else(err_status, ok_buffer) } + +/// Check a codec-encoded plan without a cipher: `STATUS_ENCODING` for a +/// declaration the engine would refuse ([`ops::plan_check`]), an empty +/// output buffer for one it accepts. Works in every instance state, before +/// [`se_cipher_init`] and after [`se_shutdown`] alike: the generator that +/// asks has no credentials and makes no request. +/// +/// # Safety +/// +/// As for [`se_term`]. +#[no_mangle] +pub unsafe extern "C" fn se_plan_check(plan_ptr: *const u8, plan_len: u32) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + // SAFETY: a host-owned range the export was handed; the borrow ends + // before it returns. + let plan = unsafe { input(plan_ptr, plan_len)? }; + ops::plan_check(plan).map(|()| Vec::new()) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// The EQL types this build produces, as a codec-encoded +/// `{"targets": [...]}` ([`ops::targets`]). Needs no cipher. +#[no_mangle] +pub extern "C" fn se_targets() -> u64 { + catch_unwind(AssertUnwindSafe(ops::targets)) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} diff --git a/languages/golang/stackencrypt/guest/src/config.rs b/languages/golang/encrypt/guest/src/config.rs similarity index 100% rename from languages/golang/stackencrypt/guest/src/config.rs rename to languages/golang/encrypt/guest/src/config.rs diff --git a/languages/golang/encrypt/guest/src/deterministic.rs b/languages/golang/encrypt/guest/src/deterministic.rs new file mode 100644 index 000000000..e58a26e74 --- /dev/null +++ b/languages/golang/encrypt/guest/src/deterministic.rs @@ -0,0 +1,13 @@ +//! The deterministic key source of the `deterministic-kms` test build: +//! `stack-kms`'s [`DeterministicSource`] (behind its `test-support` feature), +//! the one definition that also sealed stack-encrypt's record fixture +//! (`tests/fixtures/record_lowering.json`). A Go test that loads this build +//! with the fixture's seed opens the records Rust sealed and derives the same +//! term bytes, because both run the same code. +//! +//! It is a test double. The feature that compiles it is off by default, and +//! the build it produces goes under languages/golang/encrypt/testdata, which +//! `go build` and the package's `//go:embed wasm` both ignore, so no Go +//! binary carries it; the tests read it from disk. + +pub use stack_kms::DeterministicSource; diff --git a/languages/golang/stackencrypt/guest/src/headers.rs b/languages/golang/encrypt/guest/src/headers.rs similarity index 100% rename from languages/golang/stackencrypt/guest/src/headers.rs rename to languages/golang/encrypt/guest/src/headers.rs diff --git a/languages/golang/stackencrypt/guest/src/host.rs b/languages/golang/encrypt/guest/src/host.rs similarity index 100% rename from languages/golang/stackencrypt/guest/src/host.rs rename to languages/golang/encrypt/guest/src/host.rs diff --git a/languages/golang/stackencrypt/guest/src/lib.rs b/languages/golang/encrypt/guest/src/lib.rs similarity index 90% rename from languages/golang/stackencrypt/guest/src/lib.rs rename to languages/golang/encrypt/guest/src/lib.rs index 93f6aa365..711473d24 100644 --- a/languages/golang/stackencrypt/guest/src/lib.rs +++ b/languages/golang/encrypt/guest/src/lib.rs @@ -44,6 +44,12 @@ //! would cross TLS anyway. The client key enters guest memory once at //! `se_cipher_init`; derived data keys and index keys never leave. //! +//! The exports are the record path (`se_encrypt_record`, `se_decrypt_record`), +//! per-field term derivation (`se_term`), keyset resolution (`se_keyset`), +//! the generator's two questions (`se_plan_check`, `se_targets`) and the +//! lifetime pair (`se_cipher_init`, `se_shutdown`). There is no whole-value +//! export: the Go SDK seals every value under a declaration (ADR-0007). +//! //! One instance is one client: `se_cipher_init` runs once per instance and //! the keysets that client uses are selected per call through the options //! object ([`options`]), loaded on first use. There is no cipher handle, @@ -72,6 +78,8 @@ //! out of a tree is exactly what a database column holds. pub mod config; +#[cfg(feature = "deterministic-kms")] +pub mod deterministic; pub mod headers; pub mod ops; pub mod options; diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/encrypt/guest/src/ops.rs similarity index 83% rename from languages/golang/stackencrypt/guest/src/ops.rs rename to languages/golang/encrypt/guest/src/ops.rs index e0634a6f3..8eaf878c6 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/encrypt/guest/src/ops.rs @@ -19,6 +19,10 @@ //! are attacker-reachable decode/decrypt paths, and the status codes leak //! only the failure class (see `status.rs`). //! +//! There is no whole-value encrypt or decrypt here. Every value the Go SDK +//! seals goes through a declaration (ADR-0007, amended): an opaque struct +//! is a one-field record, so the record path is the one path. +//! //! # What is here, and what is not //! //! The operations themselves live in [`stack_encrypt::dynamic`]: reading a @@ -35,7 +39,7 @@ use stack_encrypt::dynamic::{self, Scalar, Scope}; use stack_encrypt::sem::MatchOptions; use stack_encrypt::target::IndexSpec; use stack_encrypt::{ - BoxedPassthrough, CipherText, Element, Encrypt, KeysetCipher, SealedValue, StackCipherText, + BoxedPassthrough, CipherText, KeysetCipher, Label, SealedValue, StackCipherText, }; use stack_kms::DataKeySource; use vitaminc_aead_value::{transport as codec, FfiValue}; @@ -57,90 +61,6 @@ pub const TERM_OPE: u32 = 4; /// encoding — the shape that crosses the FFI codec. type BytesTree = CipherText, BoxedPassthrough>; -// ============================================================================= -// Whole-value encrypt / decrypt (the vitaminc guest's vc_encrypt shape) -// ============================================================================= - -/// Encrypt a codec-encoded [`FfiValue`] tree under `aad`, sealing every leaf -/// against a fresh ZeroKMS data key (one batched request; see the module -/// docs for how a batch is chunked). With `as_element`, -/// seal it as a *sequence element* — interchangeable with rows written by -/// encrypting a whole sequence under the same AAD. -/// -/// This is the cipher-directed path, and it takes the AAD as `StackCipher` -/// does: any bytes, including none. An empty `aad` seals under no context — -/// the plain AEAD use `Aes256Cipher` allows, opened symmetrically by -/// [`decrypt_value`] — and is the Go caller's choice to make. The record and -/// term paths ([`encrypt_record`], [`decrypt_record`], [`term`]) are the -/// ones that bind fields: each takes a [`NonEmpty`](stack_encrypt::NonEmpty) context, proven once at -/// the boundary when the plan or the term's context is parsed, and refused -/// as [`STATUS_ENCODING`] when empty. -pub async fn encrypt_value( - cipher: &KeysetCipher<'_, K>, - value: &[u8], - aad: &[u8], - as_element: bool, -) -> Result, u32> -where - K: DataKeySource + Sync, -{ - let value = decode_value(value)?; - let tree = if as_element { - Element(value).encrypt_with_aad(cipher, aad) - } else { - value.encrypt_with_aad(cipher, aad) - } - .map_err(|_| STATUS_INTERNAL)?; - let ct = tree - .seal(cipher, aad) - .await - .map_err(|e| status_for_error(&e))?; - encode_tree(ct) -} - -/// Decrypt a codec-encoded ciphertext tree back into a codec-encoded -/// [`FfiValue`] tree. The output buffer contains plaintext — the ABI -/// layer's ownership rules govern its wiping. -/// -/// One batched `retrieve_keys` per invocation, dispatched as one ZeroKMS -/// call per 500 keyed leaves and, under [`Scope::Client`], per keyset the -/// tree's leaves were sealed under — the same rule [`decrypt_record`] -/// states. A tree small enough and single-keyset enough is the one request -/// that suggests; nothing here promises it in general. -/// -/// Symmetric with [`encrypt_value`]: the AAD is whatever the value was sealed -/// under, empty included. The [`Scope`] says which keysets may be opened: -/// any, or one, refusing the rest before any key is retrieved. -pub async fn decrypt_value( - scope: Scope<'_, K>, - ciphertext: &[u8], - aad: &[u8], - as_element: bool, -) -> Result, u32> -where - K: DataKeySource + Sync, -{ - let tree = decode_tree(ciphertext)?; - // One `decrypt` per arm, not one `decipher` and two drives. The element - // derivation is `Element`'s to apply and naming the type is what asks - // for it; the scope decides whether a foreign leaf is refused before any - // key is retrieved. Only one arm runs, so the retrieve happens once. - let value: FfiValue = match (&scope, as_element) { - (Scope::Client(cipher), true) => cipher - .decrypt::, _>(tree, aad) - .await - .map(Element::into_inner), - (Scope::Client(cipher), false) => cipher.decrypt(tree, aad).await, - (Scope::Keyset(keyset), true) => keyset - .decrypt::, _>(tree, aad) - .await - .map(Element::into_inner), - (Scope::Keyset(keyset), false) => keyset.decrypt(tree, aad).await, - } - .map_err(|e| status_for_error(&e))?; - encode_value(value) -} - // ============================================================================= // Terms // ============================================================================= @@ -247,6 +167,14 @@ where /// same plan. Only the `"c"` and `"passthrough"` outputs participate (terms /// are one-way). /// +/// `expected` is the context the host expects each record's context field +/// to hold, from the options object's `context` key (see +/// [`crate::options`]), for a plan that takes its context from a field; a +/// record whose stored context differs is +/// [`STATUS_CONTEXT_MISMATCH`](crate::status::STATUS_CONTEXT_MISMATCH) +/// before any key is retrieved. `None` opens each record under the +/// context it stores. +/// /// The plan's opener runs in the engine; awaiting it here is the one /// batched `retrieve_keys` per invocation, dispatched as one ZeroKMS call /// per 500 keyed leaves and, under [`Scope::Client`], per keyset the leaves @@ -256,18 +184,65 @@ pub async fn decrypt_record( scope: Scope<'_, K>, record: &[u8], plan: &[u8], + expected: Option