diff --git a/.changeset/skills-identity-docs-refresh.md b/.changeset/skills-identity-docs-refresh.md new file mode 100644 index 00000000..7925bfe7 --- /dev/null +++ b/.changeset/skills-identity-docs-refresh.md @@ -0,0 +1,13 @@ +--- +'stash': patch +'@cipherstash/stack': patch +--- + +Docs: stop teaching the deprecated `LockContext.identify()` as the primary +identity-aware-encryption path (#591). The `stash-encryption` and `stash-supabase` +skills and the `@cipherstash/stack` README now lead with the current pattern — +authenticate the client with `OidcFederationStrategy`, then bind the claim per +operation with `.withLockContext({ identityClaim })` — and demote +`LockContext.identify()` to a clearly-marked deprecated note (per-operation CTS +tokens were removed in protect-ffi 0.25). Skills ship in the `stash` tarball, so +this keeps the bundled guidance correct for the 1.0 surface. diff --git a/packages/stack/README.md b/packages/stack/README.md index 7482e794..bd0953f4 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -16,7 +16,8 @@ The all-in-one TypeScript SDK for the CipherStash data security stack. - [Schema Definition](#schema-definition) - [Encryption and Decryption](#encryption-and-decryption) - [Searchable Encryption](#searchable-encryption) -- [Identity-Aware Encryption](#identity-aware-encryption) +- [Authentication](#authentication) +- [Identity-Aware Encryption](#identity-aware-encryption-lock-contexts) - [CLI Reference](#cli-reference) - [Configuration](#configuration) - [Error Handling](#error-handling) @@ -445,37 +446,75 @@ Notes: - **The concrete type defines the legal operators.** `TextEq` supports `eq` / `ne` / `inArray` / `notInArray`; `*Ord` types add `gt` / `gte` / `lt` / `lte` / `between` / `notBetween` and `asc` / `desc`; `*Match` and `TextSearch` add `contains`; a bare `Text` / `Integer` / `Bigint` column is storage-only. Using an unsupported operator throws `EncryptionOperatorError`. - Combine conditions with `ops.and` / `ops.or`, and do NULL checks with `ops.isNull` / `ops.isNotNull` (the where-clause operators are `async` and must be `await`ed; `ops.asc` / `ops.desc` are synchronous). -## Identity-Aware Encryption +## Authentication -Lock encryption to a specific user by requiring a valid JWT for decryption. +The client authenticates to ZeroKMS through `config.authStrategy`. Leave it +unset for the default **auto** strategy: in local development, authenticate +once with `npx stash auth login` (preferred — no credentials in your +environment); in CI/production, set the `CS_*` environment variables. Two +explicit strategies cover the other cases: + +- **`AccessKeyStrategy`** — service-to-service / CI. Authenticates a *service* + with a CipherStash access key. +- **`OidcFederationStrategy`** — authenticates the client **as the end user** + by federating a third-party OIDC JWT (Clerk, Supabase, Auth0, Okta, ...) + into a CipherStash service token: ```typescript -import { LockContext } from "@cipherstash/stack/identity" +import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" + +// The callback is re-invoked on every (re-)federation and must return the +// CURRENT third-party OIDC JWT. +const strategy = OidcFederationStrategy.create( + process.env.CS_WORKSPACE_CRN!, + () => getUserJwt(), +) +if (strategy.failure) throw new Error(strategy.failure.error.message) -// 1. Create a lock context (defaults to the "sub" claim) -const lc = new LockContext() +const client = await Encryption({ + schemas: [users], + config: { authStrategy: strategy.data }, +}) +``` -// 2. Identify the user with their JWT -const identifyResult = await lc.identify(userJwt) +Authentication stands on its own — an OIDC-authenticated client encrypts and +decrypts normally. Binding *data* to the authenticated user is a separate, +optional step: the lock context, below. -if (identifyResult.failure) { - throw new Error(identifyResult.failure.message) -} +## Identity-Aware Encryption (Lock Contexts) -const lockContext = identifyResult.data +Bind a data key to a claim from the end user's JWT, so only that user can +decrypt. Chain `.withLockContext({ identityClaim })` on any operation: + +```typescript +// Requires a client authenticated with OidcFederationStrategy (above) — the +// claim's value resolves from the federated JWT. +const IDENTITY = { identityClaim: ["sub"] } -// 3. Encrypt with lock context const encrypted = await client .encrypt("sensitive data", { column: users.email, table: users }) - .withLockContext(lockContext) + .withLockContext(IDENTITY) -// 4. Decrypt with the same lock context const decrypted = await client .decrypt(encrypted.data) - .withLockContext(lockContext) + .withLockContext(IDENTITY) ``` -Lock contexts work with all operations: `encrypt`, `decrypt`, `encryptModel`, `decryptModel`, `bulkEncryptModels`, `bulkDecryptModels`, `bulkEncrypt`, `bulkDecrypt`. +Lock contexts **require** an `OidcFederationStrategy`-authenticated client +(the auto and access-key strategies authenticate no end user, so there is no +JWT to resolve claims from); plain authentication never requires a lock +context. + +`identityClaim` is an array of JWT claim *names* (`["sub"]`), not values, and the +same claim must be supplied to encrypt and decrypt. Lock contexts work with all +operations: `encrypt`, `decrypt`, `encryptModel`, `decryptModel`, +`bulkEncryptModels`, `bulkDecryptModels`, `bulkEncrypt`, `bulkDecrypt`, +`encryptQuery`. `.withLockContext()` also accepts a `LockContext` instance. + +> **Deprecated: `LockContext.identify()`.** Per-operation CTS tokens were removed +> in `protect-ffi` 0.25; the token `identify()` fetches is no longer used by +> encryption. Authenticate with `OidcFederationStrategy` and pass the claim +> directly, as above. ## CLI Reference @@ -517,7 +556,7 @@ After init, run `npx stash db setup` to configure your database. ### Local Development -No environment variables or credentials are needed for local development. Run `npx @cipherstash/stack auth login` to authenticate via the device code flow, and the SDK and CLI will use the token saved to `~/.cipherstash/auth.json`. +No environment variables or credentials are needed for local development. Run `npx stash auth login` to authenticate via the device code flow (or `npx stash init` for the agent-assisted end-to-end setup), and the SDK and CLI will use the token saved to `~/.cipherstash/auth.json`. ### Going to Production @@ -645,14 +684,13 @@ function Encryption(config: EncryptionClientConfig): Promise All operations are thenable (awaitable) and support `.withLockContext(lockContext)` for identity-aware encryption. -### `LockContext` +### `LockContext` (legacy) -```typescript -import { LockContext } from "@cipherstash/stack/identity" - -const lc = new LockContext(options?) -const result = await lc.identify(jwtToken) -``` +Identity-aware encryption is done with `OidcFederationStrategy` + +`.withLockContext({ identityClaim })` (see [Identity-Aware Encryption](#identity-aware-encryption-lock-contexts)). +`LockContext` / `identify()` remain for backwards compatibility only — the +per-operation CTS token `identify()` fetches was removed in `protect-ffi` 0.25 +and is no longer used by encryption. ### Schema Builders diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index a90b25b7..75726388 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -63,9 +63,26 @@ If you skip this step, you'll see runtime errors like `Cannot find module '@ciph ## Configuration -### Environment Variables +### Local Development (preferred: `stash auth login`) -Set these in `.env` or your hosting platform: +No environment variables are needed for local development: + +```bash +npx stash init # agent-assisted setup: auth + schema + database, end to end +# or, if the project is already set up: +npx stash auth login # device code flow; token saved to ~/.cipherstash/auth.json +``` + +`npx stash init` is the assisted flow — it authenticates, builds the encryption +schema with you, generates the client file, and wires the database. For an +already-initialized project, `npx stash auth login` alone authenticates the +machine; the SDK and CLI pick up the saved profile automatically. Sign up at +[cipherstash.com/signup](https://cipherstash.com/signup) first. + +### CI and Production (environment variables) + +Deployed environments and CI use machine credentials via environment variables +(set in your hosting platform or pipeline secrets — not committed `.env`): ```bash CS_WORKSPACE_CRN=crn:ap-southeast-2.aws:your-workspace-id @@ -74,7 +91,8 @@ CS_CLIENT_KEY=your-client-key CS_CLIENT_ACCESS_KEY=your-access-key ``` -Sign up at [cipherstash.com/signup](https://cipherstash.com/signup) to generate credentials. +When both are present, the `CS_*` variables take precedence over the saved +profile. ### Programmatic Config @@ -91,7 +109,7 @@ const client = await Encryption({ }) ``` -If `config` is omitted, the client reads `CS_*` environment variables automatically. +If `config` is omitted, the client resolves credentials automatically: `CS_*` environment variables when set (CI/production), otherwise the local `stash auth login` profile (development). ### Logging @@ -416,46 +434,83 @@ const results = await client.encryptQuery(terms) All values in the array must be non-null. -## Identity-Aware Encryption (Lock Contexts) +## Authentication -Lock encryption to a specific user by requiring a valid JWT for decryption. +The client authenticates to ZeroKMS through `config.authStrategy`. Leave it unset for the default **auto** strategy: in local development, authenticate once with `npx stash auth login` (preferred — no credentials in your environment; `npx stash init` is the agent-assisted flow that also sets up schema and database); in CI/production, set the `CS_*` environment variables. Two explicit strategies cover the other cases: -```typescript -import { LockContext } from "@cipherstash/stack/identity" +- **`AccessKeyStrategy`** — service-to-service / CI. Authenticates a *service* with a CipherStash access key. +- **`OidcFederationStrategy`** — authenticates the client **as the end user** by federating a third-party OIDC JWT (Clerk, Supabase, Auth0, Okta, ...) into a CipherStash service token: -// 1. Create a lock context (defaults to the "sub" claim) -const lc = new LockContext() -// Or with custom claims: new LockContext({ context: { identityClaim: ["sub", "org_id"] } }) -// Or with a pre-fetched CTS token: new LockContext({ ctsToken: { accessToken: "...", expiry: 123456 } }) +```typescript +import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" -// 2. Identify the user with their JWT -const identifyResult = await lc.identify(userJwt) -if (identifyResult.failure) { - throw new Error(identifyResult.failure.message) +// `getJwt` is re-invoked on every (re-)federation and must return the +// *current* third-party OIDC JWT. +const strategy = OidcFederationStrategy.create( + process.env.CS_WORKSPACE_CRN!, + () => getUserJwt(), +) +if (strategy.failure) { + throw new Error(`[auth] ${strategy.failure.type}: ${strategy.failure.error.message}`) } -const lockContext = identifyResult.data -// 3. Encrypt with lock context +const client = await Encryption({ + schemas: [users], + config: { authStrategy: strategy.data }, +}) +``` + +`OidcFederationStrategy.create()` returns a `Result` — **unwrap it**. Passing the envelope straight to `authStrategy` gives the FFI an object with no `getToken()` at all. + +> **Known type error (runtime is fine).** The example above works at runtime, but `authStrategy: strategy.data` does not currently typecheck. `@cipherstash/auth` 0.41 strategies declare `getToken(): Promise>`, while `@cipherstash/protect-ffi`'s exported `AuthStrategy` type still says `getToken(): Promise<{ token: string }>`. protect-ffi accepts **both** shapes at runtime (0.28+), on the Node and WASM paths alike — only its TypeScript declaration was left behind. Until it's widened, add `as unknown as AuthStrategy` or `// @ts-expect-error`. Tracked in [issue #602](https://github.com/cipherstash/stack/issues/602). + +Authentication stands on its own — an OIDC-authenticated client encrypts and decrypts normally. Binding *data* to the authenticated user is a separate, optional step: the lock context, below. + +## Identity-Aware Encryption (Lock Contexts) + +Bind a data key to a claim from the end user's JWT, so only that user can decrypt. Chain `.withLockContext({ identityClaim })` on any operation: + +```typescript +// Requires a client authenticated with OidcFederationStrategy (see +// "Authentication" above) — the claim's value resolves from the federated JWT. +const IDENTITY = { identityClaim: ["sub"] } + const encrypted = await client .encrypt("sensitive data", { column: users.email, table: users }) - .withLockContext(lockContext) + .withLockContext(IDENTITY) +if (encrypted.failure) { + throw new Error(`[encryption] ${encrypted.failure.type}: ${encrypted.failure.message}`) +} -// 4. Decrypt with the same lock context +// Decrypt with the SAME claim. Anything else cannot reproduce the key. const decrypted = await client .decrypt(encrypted.data) - .withLockContext(lockContext) + .withLockContext(IDENTITY) +if (decrypted.failure) { + throw new Error(`[encryption] ${decrypted.failure.type}: ${decrypted.failure.message}`) +} ``` -Lock contexts work with ALL operations: `encrypt`, `decrypt`, `encryptModel`, `decryptModel`, `bulkEncrypt`, `bulkDecrypt`, `bulkEncryptModels`, `bulkDecryptModels`, `encryptQuery`. +Lock contexts **require** an `OidcFederationStrategy`-authenticated client: the claim's value resolves from the JWT the strategy federated. The auto and access-key strategies authenticate no end user, so there is no JWT to resolve claims from — `AccessKeyStrategy` in particular authenticates a *service* and cannot be used with a lock context. Plain authentication never requires a lock context. -### CTS Token Service +Every operation returns a `Result`. Narrow on `.failure` before touching `.data`: the `Failure` branch has no `data` property, so skipping the check is a type error, not merely a runtime risk. -The lock context exchanges the JWT for a CTS (CipherStash Token Service) token. Set the endpoint: +`identityClaim` is an array of JWT claim *names*, not values: `["sub"]` (the default) or `["sub", "org_id"]`. ZeroKMS resolves each claim's value from the JWT the strategy federated. **The same claim must be supplied to encrypt and decrypt** — it is baked into the data key's tag, so decrypting without it fails with `Failed to retrieve key`. -```bash -CS_CTS_ENDPOINT=https://ap-southeast-2.aws.auth.viturhosted.net +Lock contexts work with every operation: `encrypt`, `decrypt`, `encryptModel`, `decryptModel`, `bulkEncrypt`, `bulkDecrypt`, `bulkEncryptModels`, `bulkDecryptModels`, `encryptQuery`. + +### Deprecated: `LockContext.identify()` + +Older code fetched a per-operation CTS token: + +```typescript +const lc = new LockContext() +const identified = await lc.identify(userJwt) // deprecated +await client.encrypt(...).withLockContext(identified.data) ``` +**Per-operation CTS tokens were removed in `protect-ffi` 0.25.** `LockContext`, `identify()` and `getLockContext()` still exist for backwards compatibility, but the token `identify()` fetches is no longer used by encryption — and `CS_CTS_ENDPOINT` is only read on that dead path. Authenticate with `OidcFederationStrategy` instead and pass the claim directly. `.withLockContext()` accepts either a `LockContext` instance or a plain `{ identityClaim }`. + ## Multi-Tenant Encryption (Keysets) Isolate encryption keys per tenant: diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index d8046c9a..455d5363 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -47,6 +47,12 @@ CREATE EXTENSION IF NOT EXISTS eql_v2; ## Setup +**Credentials first:** for local development run `npx stash init` (the +agent-assisted flow — auth, schema, and database end to end) or +`npx stash auth login` (device code flow; no environment variables needed). +CI and production use the `CS_*` machine-credential environment variables — +see the `stash-encryption` skill's Configuration section. + ### 1. Define Encrypted Schema ```typescript @@ -289,25 +295,61 @@ Operator family support is currently being developed in collaboration with the S `.order()` on non-encrypted columns works normally. -## Identity-Aware Encryption +## Authentication -Chain `.withLockContext()` to tie encryption to a specific user's JWT: +The encryption client authenticates to ZeroKMS through `config.authStrategy`. +Unset, it uses the default **auto** strategy — the `npx stash auth login` +profile in local development (preferred), `CS_*` environment variables in +CI/production — which is fine for service-level encryption. To authenticate **as the end user**, federate their +third-party OIDC JWT (Clerk, Supabase, Auth0, ...) with +`OidcFederationStrategy`: ```typescript -import { LockContext } from "@cipherstash/stack/identity" +import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" + +const strategy = OidcFederationStrategy.create( + process.env.CS_WORKSPACE_CRN!, + () => getUserJwt(), // re-invoked on every (re-)federation +) +if (strategy.failure) throw new Error(strategy.failure.error.message) + +const encryptionClient = await Encryption({ + schemas: [users], + config: { authStrategy: strategy.data }, +}) +const eSupabase = encryptedSupabase({ supabaseClient, encryptionClient }) +``` -const lc = new LockContext() -const identified = await lc.identify(userJwt) -if (identified.failure) throw new Error(identified.failure.message) -const lockContext = identified.data +Authentication stands on its own — an OIDC-authenticated client runs every +query normally. Binding *data* to the authenticated user is the optional next +step: the lock context. +## Identity-Aware Encryption (Lock Contexts) + +Bind the data key to a claim from the end user's JWT by chaining +`.withLockContext({ identityClaim })` on a query. This **requires** an +`OidcFederationStrategy`-authenticated client (above) — the claim's value +resolves from the federated JWT; auto/access-key auth has no user JWT to +resolve claims from. Plain authentication never requires a lock context. + +```typescript const { data, error } = await eSupabase .from("users", users) .insert({ email: "alice@example.com", name: "Alice" }) - .withLockContext(lockContext) + .withLockContext({ identityClaim: ["sub"] }) .select("id") ``` +`identityClaim` is an array of JWT claim *names* (`["sub"]`), not values; the same +claim must be used to encrypt and decrypt. `.withLockContext()` also accepts a +`LockContext` instance. + +> **Deprecated: `LockContext.identify()`.** Older code did +> `new LockContext().identify(userJwt)` to fetch a per-operation CTS token. Those +> tokens were removed in `protect-ffi` 0.25 and the fetched token is no longer +> used by encryption. Authenticate with `OidcFederationStrategy` and pass the +> claim directly, as above. + ## Audit Logging Chain `.audit()` to attach metadata for ZeroKMS audit logging: