Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,10 @@ updates:

# ── Cargo (the root workspace — the stack-* crates) ─────────────
# The root workspace (the six stack-* crates and the three node binding
# crates) and the five detached workspaces beside it: the three cargo-fuzz
# crates and the two Go WASI guests. Each has its own Cargo.lock, and each
# is its own workspace root, so each is listed.
# crates) and the detached workspaces beside it that share its crates by
# path: the three cargo-fuzz crates, the two Go WASI guests and the Python
# SDK. Each has its own Cargo.lock, and each is its own workspace root, so
# each is listed.
- package-ecosystem: cargo
directories:
- /
Expand All @@ -260,9 +261,10 @@ updates:
- /packages/stack-encrypt/fuzz
- /languages/golang/encrypt/guest
- /languages/golang/auth/guest
- /languages/python
# Lockfile only, like the other two cargo entries. Every one of these
# workspaces shares crates by path with another: the fuzz crates and both
# guests depend on root-workspace crates, the encrypt guest also on
# workspaces shares crates by path with another: the fuzz crates, both
# guests and the Python SDK depend on root-workspace crates, the encrypt guest also on
# eql-bindings, and the EQL workspace on stack-encrypt and stack-kms. Those
# root crates take `{ workspace = true }` requirements from the root
# Cargo.toml, so a manifest edit made for any one of these lockfiles can
Expand Down
17 changes: 10 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,16 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l
- `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 — 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. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. Releases are cut by hand with `.github/workflows/release-golang.yml` (`gh workflow run release-golang.yml -f version=X.Y.Z[-pre] --ref <branch>`): because the guests are gitignored on main and Go fetches a version as the files committed at its tag, the workflow builds them, commits them on a release-only commit no branch points at, tags it `languages/golang/vX.Y.Z` and pushes the tag alone, then attaches the EQL v4 SQL bundle to the GitHub release. The ref must carry the EQL v4 bundle, or the build stops at "Build the EQL SQL bundles". `scripts/go-release-version.mjs` refuses, before anything is built, every version Go would refuse (non-canonical semver, `+build` metadata, a v2+ major on a module path with no `/vN` suffix, a version shaped like a pseudo-version), a final version dispatched from anywhere but `main`, and a final version while `languages/golang/go.mod` pins a `github.com/cipherstash/vitaminc/…` module at a pseudo-version (vitaminc's Go bindings have no tags, and a TEMPORARY pin on an unreleased commit must be gone before the module is tagged); a pre-release may come from a branch and may carry such a pin. The build job also refuses a version the Go proxy already lists in `@v/list`, which catches a tag deleted after the proxy fetched it; it never asks the proxy for the version itself before the tag exists, because the proxy would cache "not found" for up to 30 minutes and `verify` would fail after the push. The build checks the `Cargo.lock` of the EQL workspace and both guest workspaces with `--locked` first, since none of those builds passes it. It runs in a job with no write token and hands the guests to a `publish` job that holds `contents: write` and runs only git and gh. `publish` fetches the commit with plain unauthenticated git rather than `actions/checkout`, whose `token` input defaults to the job's writable token, and refuses a release commit whose tree is not the one the build tested. `publish` takes the paths it commits from the workflow's fixed `GUESTS` list, never from the artifact, and refuses a `guests.sha256` that is not exactly one SHA-256 line for each of those three guests: the build job runs third-party code after writing that manifest, so a path read from it could put any file on the tag (`scripts/__tests__/go-release-artifact.test.mjs`). A version is immutable once the Go proxy has fetched it, so a tag is never moved or deleted: if `publish` or `verify` fails after the tag is pushed, use "Re-run failed jobs" on the same run, which keeps a tag that run pushed and creates or completes the release; a new dispatch of the same version is refused. Never tag a main commit by hand: `go get` would fetch a module with no guests.
- `languages/python`: The Python SDK, `stackencrypt` — a pyo3 extension over `stack-encrypt`, built by maturin. **The fourth Cargo workspace**, excluded from the root one, with two crates: `crates/core` (no pyo3; the engine-side logic, linked and tested without a Python interpreter) and `crates/engine` (the `_engine` cdylib, `abi3-py39`, one wheel per platform for Python 3.9 to 3.14). The `extension-module` feature is set only by maturin in `pyproject.toml`, never in a crate manifest, so `cargo test` links libpython. Its tasks live in its own `mise.toml`, run from that folder: `mise run build` (wheel into `.venv`), `mise run test` (pytest), `mise run test:cargo` (both crates, `--locked`, libpython on the loader path). The wheel's version is `crates/engine/Cargo.toml`'s; `stackencrypt.__engine__` is the commit of this checkout, recorded by `crates/core/build.rs`. Its `Cargo.lock` was seeded from the root one because `stack-encrypt` requires a yanked `cllw-ore` pre-release that a fresh resolve cannot select. Nothing is published; a lockfile for the Python side and the CI workflow are the next tickets (#1383). Design: Python ADR-0001 under `languages/python/docs/adr/`, the spec in #1341.
- `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).
- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-indexing`, `stash-deployment`, `stash-zerokms`, `stash-auth`, `stash-postgres`, `stash-edge`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma`, `stash-managed-platforms`, `stash-supply-chain-security`)

## Working on protect-ffi

`languages/typescript/packages/protect-ffi` carries one of this repo's three Cargo workspaces (the
others are the root workspace, for the stack-* crates, and `packages/eql`), and its scripts are split so a Rust toolchain
`languages/typescript/packages/protect-ffi` carries one of this repo's four Cargo workspaces (the
others are the root workspace, for the stack-* crates, `packages/eql` and `languages/python`), and its scripts are split so a Rust toolchain
stays optional for everyone else.

- **The default `test` and `build` never invoke cargo.** Root `pnpm test` runs
Expand Down Expand Up @@ -715,9 +716,10 @@ term. `cts-common` and `zerokms-protocol` take caret
requirements, because the published `stack-auth` inherits them, and an
exact pin would stop the suite sharing one `cts-common` with it.

- **Three Cargo workspaces, not one.** The root workspace (the six stack-*
crates and the three node binding crates), protect-ffi's and EQL's. The root
`Cargo.toml` excludes the other two, and they pin their own vitaminc. The
- **Four Cargo workspaces, not one.** The root workspace (the six stack-*
crates and the three node binding crates), protect-ffi's, EQL's and the
Python SDK's (`languages/python`). The root `Cargo.toml` excludes the other
three; protect-ffi and EQL set their own vitaminc version. The
three fuzz crates, the two Go guests and the EQL v4 block ORE timing
harness's ciphertext generator
(`packages/eql/tests/timing/ore_block_v2/ctgen`) are detached workspaces
Expand Down Expand Up @@ -817,8 +819,9 @@ mutation in the same function stays covered.

Read [`docs/sdk-design-principles.md`](docs/sdk-design-principles.md) before you
architect, design or build a language SDK, or change the Go module's public
surface. It holds eight principles for every language SDK and thirteen for the
Go SDK, and the order to apply them in when two disagree.
surface. It holds eight principles for every language SDK, thirteen for the
Go SDK, five started for the Python SDK, and the order to apply them in when
two disagree.
`packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md` records why.

Two terms from it are used across this repository:
Expand Down
9 changes: 6 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
resolver = "2"

# The stack-* crates imported from cipherstash-suite, and the three node
# bindings that wrap them. The root is one of three Cargo workspaces in this
# repository: EQL and protect-ffi keep their own, and pin their own vitaminc
# (see `exclude`).
# bindings that wrap them. The root is one of four Cargo workspaces in this
# repository: EQL, protect-ffi and the Python SDK keep their own; the first
# two pin their own vitaminc (see `exclude`).
members = [
"packages/stack-auth",
"packages/stack-profile",
Expand All @@ -29,6 +29,9 @@ exclude = [
# `wasm:auth-guest:build`.
"languages/golang/encrypt/guest",
"languages/golang/auth/guest",
# The Python SDK: a pyo3 extension and its core crate, built by maturin
# through `mise run build` in that folder.
"languages/python",
]

[workspace.package]
Expand Down
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ from the stack-* crates), released by `.github/workflows/release-golang.yml`
as `languages/golang/vX.Y.Z` tags. All of these are in scope
for security reports on the same terms as the npm packages above.

The Python SDK at `languages/python` (`stackencrypt`, a pyo3 extension over
`stack-encrypt`) is in the tree and is not released: no wheel is published
and nothing in `.github/workflows` builds one for distribution.

The five stack-* crates are licensed under the PolyForm Internal Use License
1.0.0 (each crate's `LICENSE`), not the repository's MIT licence: they may be
used for internal business operations only, and not redistributed. That
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,18 @@ without loss, and refuses it otherwise.
- **JavaScript.** A `BigInt` takes the target's kind, and is refused if it
does not fit. It decodes as a `BigInt`. `Date` means `Timestamp`; a date
has its own wrapper.
- **Python.** The kind comes from the field's marker, never from the
annotation. The annotation must name exactly the one Python type the kind
takes (`int` for the integer kinds, `float` for the float kinds,
`decimal.Decimal`, `datetime.date`, `datetime.datetime`, `str`, `bool`),
and the class is refused when the two disagree. A value is checked by
exact type, so a `bool` is never an `int` and a `datetime` is never a
`date`, then by range: an `int` that does not fit the kind, a `float`
that does not round-trip through the kind's width, a naive `datetime` and
`None` are refused. Nothing is converted. A decrypted value comes back as
the same Python type. The rules are the Python SDK spec's (#1341,
"Values"). ADR-0001 under `languages/python/docs/adr/` records only the
decorator the annotation check runs in, not these rules.
- **Decimal.** A host decimal (Go's `shopspring/decimal`, a JavaScript
string) is refused if it is NaN or infinite, has more than 28 digits after
the point, or does not fit a 96-bit mantissa at its scale. Postgres
Expand Down
48 changes: 48 additions & 0 deletions docs/sdk-design-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
These principles apply when you architect, design or build a language SDK for Stack Encrypt.
The first part applies to every language.
The second part applies to the Go SDK.
The third part applies to the Python SDK and is a start, not a complete set.
[ADR-0008](../packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md) records the decision to adopt them.

## Terms

- **Engine:** the Rust code that encrypts, decrypts and derives search terms.
- **Binding:** the FFI or WASI interface between the engine and a target language.
- **Language SDK:** what users of the target language work with day to day.
- **Extension:** the Python SDK's binding, the module `stackencrypt._engine` in the crate `stackencrypt-engine`.
The spec #1341 fixes both names, so "engine" in them names the binding, not Stack Encrypt.
`stackencrypt.__engine__` reports the Stack Encrypt commit, the engine in this list's sense.
- **Declaration:** the statement of how each field is encrypted: its context, and its indexes.
The engine calls a saved declaration a plan.

Expand Down Expand Up @@ -215,3 +219,47 @@ The generator does not guess a plural.
- Query building in Go.
- The design of the `go vet` check.
- A change to a declaration over time.

## Principles for the Python SDK

The Python SDK is the package `stackencrypt` under `languages/python`.
Its binding is the extension module `stackencrypt._engine`, named under Terms above.
Do not call the extension the SDK, and do not call the SDK the binding.
[ADR-0001](../languages/python/docs/adr/0001-a-declaration-is-checked-when-the-class-is-defined.md) under `languages/python/docs/adr/` records the first decision below.
No code demonstrates these yet: the walking skeleton exposes only build metadata, and the tickets under spec #1341 build the rest.

### 1. Find mistakes at the earliest stage

The stages for Python are: the class statement, the cipher build, and the running program.
Python has no compiler stage, so the class statement is the earliest.
The running program finds only a mistake that depends on data.

### 2. A declaration is checked when the class is defined, by construction

One class decorator carries the context, reads the `Annotated` hints, and asks the engine to check the plan.
The check runs as the `class` statement finishes, wherever the class is defined.
The call site takes the class, as in `Cipher(User)`.
No public name is a plan or returns one.

### 3. The extension never changes a value

A value is checked by exact type, so a `bool` is never an `int`.
A value that does not fit its kind is refused, never narrowed or rounded.
The kind comes from the field's marker, never from the Python type.

### 4. Fit the way Python code reaches a database

The SDK does not own a connection.
It encrypts a model into the column values a psycopg statement takes, and decrypts the rows psycopg returns.
Calls are synchronous and release the GIL while the engine works.

### 5. Never put plaintext in an error or a print

Every error names the field and the reason, and never the value.
A declared model's `__repr__` and `__str__` show `[sealed]` for encrypted fields by default.

### Not yet decided for Python

- The SQLAlchemy layer, which is its own distribution and its own spec.
- An async surface.
- A change to a declaration over time.
6 changes: 6 additions & 0 deletions languages/python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# The virtualenv `mise run build` creates, and the extension an editable
# install writes next to the package. `target/` is ignored at the repo root.
.venv/
*.so
*.pyd
.pytest_cache/
Loading
Loading