Skip to content

fix(stack-auth,stack-profile): clamp server expires_in and wipe secret buffers - #1553

Draft
cipherstash-agent wants to merge 3 commits into
mainfrom
claude/project-thread-n479p0
Draft

cipherstash-agent wants to merge 3 commits into
mainfrom
claude/project-thread-n479p0

Conversation

@cipherstash-agent

Copy link
Copy Markdown
Contributor

Requested by CipherStash · project thread

Summary

Fixes CIP-5159 (R3 and R4 from the weekly review).

  1. Unchecked expires_in arithmetic. The auth server's expires_in was added to the clock with no limit. On the token paths, now + expires_in panics in debug builds and wraps in release builds, because the release profile has no overflow-checks. A wrapped value gives a token that is already expired, which causes a refresh loop. The device-code deadline, Instant::now() + Duration::from_secs(expires_in), panics in every build profile.
  2. Secrets in plain Strings that are never wiped. The JSON for auth.json and secretkey.json passed through to_string_pretty and read_to_string buffers that were freed without being wiped. CS_CLIENT_ACCESS_KEY was held as a bare String until it was parsed.

Changes

  • token::expires_at_after(now, expires_in) is now the single place where a server expires_in becomes expires_at. It caps the lifetime at 30 days (MAX_TOKEN_LIFETIME_SECS) and uses a saturating add, the same rule is_expired_at already follows. Both Token::refresh and device-code polling use it.
  • The device-code lifetime is capped at one hour (MAX_DEVICE_CODE_LIFETIME_SECS) when the code is received, so the poll deadline cannot overflow. PendingDeviceCode::expires_in() reports the lifetime that is actually enforced. The debug trace logs both that value and the server's value.
  • stack-profile has a new private SecretBuf. It is a Zeroizing<Vec<u8>> that never lets the Vec reallocate itself. When it needs more room, it copies into a larger buffer and wipes the old one. ProfileStore::write serialises into it with serde_json::to_writer_pretty. ProfileStore::load reads the file straight into it with SecretBuf::read_from and parses with from_slice. fs::read was not used because it reallocates, and leaves unwiped copies, whenever file metadata under-reports the file's length.
  • AutoStrategyBuilder holds the access key as a SecretToken, which wipes on drop and masks Debug. It wraps the key as soon as it reads it from the environment. detect_inner now borrows the key as Option<&str>.
  • Adds a @cipherstash/auth patch changeset.

Verification

  • cargo test -p stack-auth -p stack-profile --all-features: all pass. These are the stack-auth lib tests (297), stack-profile (67), and the integration and doc tests.
  • Red first: with the arithmetic reverted, the five new expiry tests fail. Four panic with overflow, and the deadline test panics inside tokio's Instant add.
  • cargo clippy --all-targets -D warnings passes with all features and with no default features. Clippy also passes for stack-profile on wasm32-wasip1 (lib). cargo fmt --check and cargo doc are clean.
  • I could not run scripts/check-auth-npm-changeset.mjs locally because there are no node_modules. The changeset follows the format of the existing @cipherstash/auth ones.

Review notes

  • Out of scope, worth a follow-up: /api/authorise returns an absolute expiry (authorize_dto.rs). It involves no arithmetic, so it cannot overflow. It is still unbounded, though: a hostile expiry: u64::MAX would stop AutoRefresh from ever refreshing. Capping it means passing now into the From<AuthoriseResponse> mapping, which touches the refreshers that CIP-5152 and CIP-5158 are also changing, so I left it out of this PR.
  • Known limit: when a JSON string contains escape sequences, serde_json decodes it through its own scratch buffer, and that buffer is not wiped. Tokens and keys are base64 or JWTs and contain no escapes, so they never take that path. The comment in load says so.
  • The process environment block still holds its own copy of CS_CLIENT_ACCESS_KEY. That copy is outside the crate's reach.
  • Nearby work: auto_strategy.rs is also touched by the CIP-5158 thread (CS_WORKSPACE_CRN). This PR only changes the access-key lines and drops .into() from four detect_inner test calls.

Related

  • Linear: CIP-5159
  • CIP-3435 (ServiceToken zeroize) covers a different type.

Generated by Claude Code

…t buffers

A server-supplied `expires_in` was added to the clock unchecked. On the
token paths `now + expires_in` panics in debug and wraps in release (no
overflow-checks), giving an already-expired token and a refresh loop; the
device-code deadline `Instant::now() + Duration` panics in every profile.
Token lifetimes now go through one helper, `expires_at_after`, which clamps
to 30 days and saturates, the rule `is_expired_at` already followed. Device
codes are clamped to one hour when received, so the deadline cannot
overflow and `PendingDeviceCode::expires_in()` reports what is enforced.

The JSON that carries `auth.json` and `secretkey.json` to and from disk was
held in plain Strings and freed unwiped, undercutting `ZeroizeOnDrop` on
the secrets inside. It is now serialised into a writer that wipes on drop
and on every growth (a Zeroizing<Vec> alone frees its old allocation
unwiped when it reallocates), and read through `fs::read`, which sizes the
buffer once, into a Zeroizing<Vec<u8>>. The access key from
`CS_CLIENT_ACCESS_KEY` or `with_access_key` is held as Zeroizing<String>
until it is parsed into an `AccessKey`.

Fixes CIP-5159.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Jmn7qCTmGiT6hfLv8rxDP
…wiping

- Read profile files through `SecretBuf::read_from` rather than `fs::read`.
  `fs::read` only avoids reallocating when the file's metadata length is
  right; on a filesystem that under-reports it, the Vec grows and frees
  unwiped copies, and the final probe read goes through an unwiped stack
  buffer. `SecretBuf` (renamed from `ZeroizingWriter`) reads straight into
  its own wiped buffer.
- Hold the builder's access key as a `SecretToken`, the crate's existing
  secret type, rather than a bare `Zeroizing<String>`, so it is also masked
  in `Debug`.
- Log the enforced device-code lifetime next to the server's, so the trace
  matches what polling does.
- Drop a test that called `reserve` itself and so could not catch `write`
  skipping it; add `read_from` tests for short, interrupted and failed reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Jmn7qCTmGiT6hfLv8rxDP
@cipherstash-agent cipherstash-agent self-assigned this Nov 6, 2026
@changeset-bot

changeset-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dd043af

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 19 packages
Name Type
@cipherstash/auth Patch
stash Patch
@cipherstash/stack Patch
@cipherstash/wizard Patch
@cipherstash/ffi-integration-tests Patch
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/bench Patch
@cipherstash/stack-drizzle Patch
@cipherstash/stack-prisma Patch
@cipherstash/stack-supabase Patch
@cipherstash/test-kit Patch
@cipherstash/prisma-example Patch
@cipherstash/auth-darwin-arm64 Patch
@cipherstash/auth-darwin-x64 Patch
@cipherstash/auth-linux-arm64-gnu Patch
@cipherstash/auth-linux-x64-gnu Patch
@cipherstash/auth-linux-x64-musl Patch
@cipherstash/auth-win32-x64-msvc Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Mutation testing (cargo-mutants, --in-diff, stack-auth + stack-encrypt)

caught missed unviable timeout
10 0 5 0

Every mutant in the changed lines was caught by a test.

…ckfiles

stack-profile gained a `zeroize` dependency, and six detached Cargo
workspaces resolve it by path: both Go guests, EQL and the three fuzz
crates. The Go guest job checks its lockfiles with --locked and failed on
the stale entry. Each lockfile gains only the one dependency line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Jmn7qCTmGiT6hfLv8rxDP

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants