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..121b9071 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -447,35 +447,49 @@ Notes: ## Identity-Aware Encryption -Lock encryption to a specific user by requiring a valid JWT for decryption. +Bind a data key to a claim from the end user's JWT, so only that user can +decrypt. Do it in two parts: **authenticate the client as the user** with +`OidcFederationStrategy`, then **name the claim** on each operation with +`.withLockContext({ identityClaim })`. ```typescript -import { LockContext } from "@cipherstash/stack/identity" +import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" -// 1. Create a lock context (defaults to the "sub" claim) -const lc = new LockContext() - -// 2. Identify the user with their JWT -const identifyResult = await lc.identify(userJwt) +// 1. Authenticate the client as the end user. The callback returns the current +// third-party OIDC JWT (Clerk, Supabase, Auth0, Okta, ...). +const strategy = OidcFederationStrategy.create( + process.env.CS_WORKSPACE_CRN!, + () => getUserJwt(), +) +if (strategy.failure) throw new Error(strategy.failure.error.message) -if (identifyResult.failure) { - throw new Error(identifyResult.failure.message) -} +const client = await Encryption({ + schemas: [users], + config: { authStrategy: strategy.data }, +}) -const lockContext = identifyResult.data +// 2. Bind the data key to the user's `sub` claim on encrypt AND decrypt. +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`. +`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 @@ -645,14 +659,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)). +`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..043edc61 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -416,46 +416,74 @@ const results = await client.encryptQuery(terms) All values in the array must be non-null. -## Identity-Aware Encryption (Lock Contexts) +## Identity-Aware Encryption -Lock encryption to a specific user by requiring a valid JWT for decryption. +Bind a data key to a claim from the end user's JWT, so only that user can decrypt. + +You do this in two parts: **authenticate the client as the user** with `OidcFederationStrategy`, then **name the claim** on each operation with `.withLockContext({ identityClaim })`. ```typescript -import { LockContext } from "@cipherstash/stack/identity" +import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" + +// 1. Authenticate the client as the end user. `getJwt` is re-invoked on every +// (re-)federation and must return the *current* third-party OIDC JWT +// (Clerk, Supabase, Auth0, Okta, ...). +const strategy = OidcFederationStrategy.create( + process.env.CS_WORKSPACE_CRN!, + () => getUserJwt(), +) +if (strategy.failure) { + throw new Error(`[auth] ${strategy.failure.type}: ${strategy.failure.error.message}`) +} -// 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 } }) +const client = await Encryption({ + schemas: [users], + config: { authStrategy: strategy.data }, +}) -// 2. Identify the user with their JWT -const identifyResult = await lc.identify(userJwt) -if (identifyResult.failure) { - throw new Error(identifyResult.failure.message) -} -const lockContext = identifyResult.data +// 2. Bind the data key to the user's `sub` claim. +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) +if (encrypted.failure) { + throw new Error(`[encryption] ${encrypted.failure.type}: ${encrypted.failure.message}`) +} -// 4. Decrypt with the same lock context +// 3. 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`. +> **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 0.28 accepts **both** shapes at runtime, 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). -### CTS Token Service +`OidcFederationStrategy.create()` returns a `Result` — **unwrap it**. Passing the envelope straight to `authStrategy` gives the FFI an object with no `getToken()` at all. -The lock context exchanges the JWT for a CTS (CipherStash Token Service) token. Set the endpoint: +Every operation returns a `Result` too. 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. -```bash -CS_CTS_ENDPOINT=https://ap-southeast-2.aws.auth.viturhosted.net +`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`. + +Lock contexts work with every operation: `encrypt`, `decrypt`, `encryptModel`, `decryptModel`, `bulkEncrypt`, `bulkDecrypt`, `bulkEncryptModels`, `bulkDecryptModels`, `encryptQuery`. + +`AccessKeyStrategy` is the service-to-service / CI path. It authenticates a *service*, not a user, so it cannot be used with a lock context. + +### 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..69f78749 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -291,23 +291,45 @@ Operator family support is currently being developed in collaboration with the S ## Identity-Aware Encryption -Chain `.withLockContext()` to tie encryption to a specific user's JWT: +Bind encryption to a specific user in two parts: **authenticate the client as the +user** with `OidcFederationStrategy` when you build it, then **name the claim** on +each query with `.withLockContext({ identityClaim })`. ```typescript -import { LockContext } from "@cipherstash/stack/identity" +import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" -const lc = new LockContext() -const identified = await lc.identify(userJwt) -if (identified.failure) throw new Error(identified.failure.message) -const lockContext = identified.data +// 1. Authenticate the client as the end user (getUserJwt returns the current +// third-party OIDC JWT — Clerk, Supabase, Auth0, ...). +const strategy = OidcFederationStrategy.create( + process.env.CS_WORKSPACE_CRN!, + () => getUserJwt(), +) +if (strategy.failure) throw new Error(strategy.failure.error.message) + +const encryptionClient = await Encryption({ + schemas: [users], + config: { authStrategy: strategy.data }, +}) +const eSupabase = encryptedSupabase({ /* ...supabase config... */, encryptionClient }) +// 2. Bind the data key to the user's `sub` claim on each query. 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: