Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/validate-hosted-zerokms-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@cipherstash/auth': patch
'stash': patch
'@cipherstash/eql': minor
---

Validate token-discovered ZeroKMS destinations before sending bearer credentials, including device provisioning during login. Discovery accepts only HTTPS endpoints for supported regions under the CipherStash ZeroKMS domains. Once ZeroKMS has accepted a token, every later token must carry a valid hosted claim for that region; missing, non-hosted, or different-region claims are refused. A token ZeroKMS refuses does not fix the region. Same-region domain migrations update the destination used by long-lived clients. Explicit KMS configuration remains authoritative; staging and regions unknown to the SDK require explicit configuration or an SDK upgrade.

The Rust releases are `stack-auth`/`stack-profile` 0.43.1, `stack-kms` 0.2.0, and `stack-encrypt`/`stack-encrypt-derive` 0.3.0. The pending EQL Rust encryption integration uses `stack-encrypt` 0.3.0. Custom `ZeroKMSConnection` transports receive the discovered endpoint as a third `send` argument, and use it only when they were not configured with one; `ensure_base_url` is removed. The CLI's bundled agent guidance describes these discovery requirements.
11 changes: 6 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ incremental = true
# but cannot subtract them, and `stack-kms` / `stack-encrypt` need stack-auth
# without `http`. Consumers that want the default transport re-enable it with
# `features = ["http"]`.
stack-auth = { path = "./packages/stack-auth", version = "0.43.0", default-features = false }
stack-profile = { path = "./packages/stack-profile", version = "0.43.0" }
stack-auth = { path = "./packages/stack-auth", version = "0.43.1", default-features = false }
stack-profile = { path = "./packages/stack-profile", version = "0.43.1" }

# Suite crates, from crates.io; Cargo.lock holds the exact versions.
# cts-common and zerokms-protocol take caret requirements because the
Expand Down
4 changes: 2 additions & 2 deletions languages/golang/auth/guest/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion languages/golang/encrypt/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,9 @@ func clientKeyFromProfile(ctx context.Context, profile *auth.ProfileStore, noPro

// zerokmsEndpoint is the endpoint NewClient pins, in stack-kms's order: the
// explicit URL, else the first of CS_ZEROKMS_HOST and CS_VITUR_HOST that is
// set, else none (the token's services claim decides on first use). A set
// set, else none (the token's services claim decides on first use, and may
// only name a CipherStash-hosted ZeroKMS: https://<region>.<provider>.viturhosted.net
// or https://<region>.<provider>.zerokms.cipherstashmanaged.net). A set
// variable that is not a usable endpoint is an error, not a fall-through:
// falling through would send key operations somewhere the operator did not
// configure. The guest validates the URL in full; this check exists to name
Expand Down
11 changes: 6 additions & 5 deletions languages/golang/encrypt/guest/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 9 additions & 29 deletions languages/golang/encrypt/guest/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
//! are reclaimed via the registry (which the ABI's `se_dealloc` also wipes).

use std::convert::Infallible;
use std::sync::Mutex;

use stack_auth::{AuthError, AuthStrategy, CustomError, SecretToken, ServiceToken};
use stack_guest_abi::buffers;
Expand All @@ -51,52 +50,32 @@ extern "C" {
/// import. The host call is synchronous from the guest's perspective, so
/// `send` resolves immediately — `block_on` in the ABI layer never parks.
///
/// The endpoint is pinned at init when the cipher config named one;
/// otherwise it is discovered by `StackKms` from the access token's
/// `services` claim through [`ensure_base_url`](Self::ensure_base_url) —
/// the first value wins, per the trait's contract.
/// The endpoint is fixed at init when the cipher config named one;
/// otherwise each request goes to the endpoint `StackKms` resolved from the
/// access token's `services` claim and passes to [`send`](Self::send).
pub struct WasiHostConnection {
base: Mutex<Option<ZeroKmsEndpoint>>,
}

impl WasiHostConnection {
fn base(&self) -> std::sync::MutexGuard<'_, Option<ZeroKmsEndpoint>> {
// Wasm is single-threaded: a poisoned lock can only mean a previous
// panic already aborted the instance, so this is unreachable —
// recover rather than add a second panic path.
self.base
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
base: Option<ZeroKmsEndpoint>,
}

impl ZeroKMSConnectionInit for WasiHostConnection {
type ConnectionOpts = Option<ZeroKmsEndpoint>;
type Error = Infallible;

fn init(opts: Self::ConnectionOpts) -> Result<Self, Infallible> {
Ok(Self {
base: Mutex::new(opts),
})
Ok(Self { base: opts })
Comment on lines -78 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was the Mutex removed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The connection no longer changes after init, so nothing needs locking. The Mutex was there because ensure_base_url wrote the discovered endpoint into the connection after construction. Now StackKms passes the endpoint for each request as an argument to send, and ensure_base_url is gone. base is set once in init from the configured endpoint (if there is one) and only read after that. Each request uses base, or the endpoint passed in if there is no base. This is the same change as in HttpConnection.


Generated by Claude Code

}
}

impl ZeroKMSConnection for WasiHostConnection {
fn ensure_base_url(&self, url: ZeroKmsEndpoint) {
let mut base = self.base();
if base.is_none() {
*base = Some(url);
}
}

fn has_base_url(&self) -> bool {
self.base().is_some()
self.base.is_some()
}

async fn send<Request: ViturRequest>(
&self,
request: Request,
access_token: &str,
endpoint: Option<&ZeroKmsEndpoint>,
) -> Result<Request::Response, ViturRequestError> {
// Defence in depth, shared verbatim with `HttpConnection`:
// `StackKms::get_token` resolves the endpoint (or fails with
Expand All @@ -107,8 +86,9 @@ impl ZeroKMSConnection for WasiHostConnection {
// caller from mistaking a missing endpoint for a 401 and refreshing
// in a loop.
let url = self
.base()
.base
.as_ref()
.or(endpoint)
.map(|base| base.request_url(Request::ENDPOINT))
.ok_or_else(|| {
ViturRequestError::prepare(
Expand Down
4 changes: 2 additions & 2 deletions languages/golang/encrypt/guest/src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,8 @@ pub fn status_for_dynamic(error: &stack_encrypt::dynamic::Error) -> u32 {
}
}

// These matches are deliberately exhaustive — no `_` arms. None of the
// stack-kms error enums is `#[non_exhaustive]`, so exhaustiveness is free
// These matches are deliberately exhaustive — no `_` arms. The outer KMS
// error and request error enums matched here are exhaustive, so this is free
// compiler coverage: `GenerateKeyError` already grew `Unauthorized` /
// `Forbidden` out of the shared `From<ViturRequestError>` pattern, and a
// variant added tomorrow must be classified here before this crate builds,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export class MockCtsServer {
#server: Server
#routes = new Map<string, Handler>()
#baseUrl = ''
#requestCount = 0

private constructor(server: Server) {
this.#server = server
Expand All @@ -26,6 +27,7 @@ export class MockCtsServer {
static async start(): Promise<MockCtsServer> {
const mock = new MockCtsServer(
createServer((req, res) => {
mock.#requestCount += 1
const handler = mock.#routes.get(`${req.method} ${req.url}`)
if (!handler) {
res.writeHead(404, { 'content-type': 'application/json' })
Expand Down Expand Up @@ -205,6 +207,11 @@ export class MockCtsServer {
}))
}

/** How many requests reached the server, matched or not. */
get requestCount(): number {
return this.#requestCount
}

clearMocks(): void {
this.#routes.clear()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,21 @@ describe('provision device client (TypeScript / vitest)', () => {
await server.close()
})

it('creates secretkey.json on successful provisioning', async () => {
// A profile token's ZeroKMS claim is unverified, so provisioning only
// sends the bearer to a CipherStash-hosted ZeroKMS. This mock server is not
// one, so the binding cannot reach it: the success and 409 paths are
// covered by stack-auth's Rust tests through its transport seam, and here
// the binding must refuse before any request leaves.
it('refuses a token naming a ZeroKMS outside CipherStash, before sending it', async () => {
server.mockCreateClientEndpoint()
saveTestToken(profileDir, server.baseUrl)

const r = await bindClientDevice()
if (r.failure) {
expect.unreachable(`bindClientDevice failed: ${r.failure.type}`)
}

const raw = readFileSync(join(workspaceDir(), 'secretkey.json'), 'utf-8')
const secretKey = JSON.parse(raw)
expect(secretKey.client_id).toBe('00000000-0000-0000-0000-000000000001')
expect(secretKey.client_key).toBe('dGVzdC1rZXktbWF0ZXJpYWw=')
expect(r.failure?.type).toBe('INVALID_TOKEN')
expect(r.failure?.error).toBeInstanceOf(Error)
expect(server.requestCount).toBe(0)
expect(existsSync(join(workspaceDir(), 'secretkey.json'))).toBe(false)
})

it('is a no-op when secretkey.json already exists', async () => {
Expand All @@ -84,27 +86,6 @@ describe('provision device client (TypeScript / vitest)', () => {
expect(secretKey.client_id).toBe('existing-id')
})

it('is a no-op on 409 conflict', async () => {
server.mockCreateClientConflict()
saveTestToken(profileDir, server.baseUrl)

const r = await bindClientDevice()
if (r.failure) {
expect.unreachable(`bindClientDevice failed: ${r.failure.type}`)
}

expect(existsSync(join(workspaceDir(), 'secretkey.json'))).toBe(false)
})

it('fails on server error', async () => {
// No mock endpoint — server will return an error for unmatched route.
saveTestToken(profileDir, server.baseUrl)

const r = await bindClientDevice()
expect(r.failure).toBeTruthy()
expect(r.failure?.error).toBeInstanceOf(Error)
})

it('fails with STORE_ERROR when auth token is missing', async () => {
// No token saved — should fail trying to load auth.json
const r = await bindClientDevice()
Expand Down
17 changes: 9 additions & 8 deletions packages/eql/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading