Skip to content

fix: harden auth, secrets at rest, payments, and webhooks - #1

Merged
nwilkens merged 16 commits into
triton-mainfrom
security-fixes
Aug 21, 2026
Merged

nwilkens merged 16 commits into
triton-mainfrom
security-fixes

Conversation

@nwilkens

Copy link
Copy Markdown
Member

Hardening across authentication, secret storage at rest, payment method handling, and inbound webhook verification, plus one dependency advisory bump.

Nine fixes, one per commit. Each carries a regression test that was verified to fail against the previous behavior. Rationale is in the individual commit messages.

Verification

cargo test --workspace --lib and the 288-test integration suite pass. cargo audit exits clean.

Deployment

The secret-storage change needs a coordinated restart: values written by this build cannot be read by the previous binary, and a startup migration rewrites existing rows before listeners open. Rollback requires the pre-migration backup. Procedure and rationale are in docs/superpowers/specs/2026-08-19-critical-billing-security-design.md.

Operators should rotate stored provider credentials after upgrading.

Scope

Further work is identified but deliberately not in this PR, to keep each change reviewable on its own.

馃 Generated with Claude Code

https://claude.ai/code/session_011WiwhEunTESEw8qgKPHnpk

nwilkens and others added 14 commits August 19, 2026 14:45
The REST and gRPC authorization caches were keyed by ApiTokenId alone, so once
a valid key warmed an entry, any secret carrying that same token id reused it
and skipped Argon2 verification entirely.

Both transports now go through one verification component keyed by a SHA-256
digest of the decoded token id and the presented secret. Unknown token and
wrong secret collapse to one Unauthorized result so the response cannot confirm
a guessed token id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WiwhEunTESEw8qgKPHnpk
Connector secrets and OAuth PKCE verifiers were encrypted with ChaCha20-Poly1305
using the first 12 bytes of the encryption key as the nonce, so every record
under a key shared one nonce. That leaks plaintext XOR across records and reuses
the Poly1305 key, which also permits forgery.

New ciphertext is written as mtr1:<nonce hex>:<ciphertext hex> with a nonce from
the OS CSPRNG. Decryption still reads the legacy bare-hex format so a startup
migration can rewrite existing rows; it runs before any listener opens, in one
locked transaction, and fails the boot rather than commit a partial rewrite.

Re-encryption does not un-expose values readable in an older database snapshot,
so the migration logs a warning to rotate the affected provider credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WiwhEunTESEw8qgKPHnpk
calculate_slot_upgrade_amount already returns the prorated total for the whole
seat delta, but create_slot_upgrade_invoice_internal treated it as a per-unit
rate and multiplied by delta again. preview_slot_update does not, so a 5-seat
upgrade quoted $27.42 was invoiced $137.10.

Bills the prorated total directly, derives the display unit price from it, and
rejects a non-positive delta where the invariant is relied on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WiwhEunTESEw8qgKPHnpk
The metering ingestion listener cached authorization by token id, the same
defect just fixed on the meteroid API. A forged secret carrying a known token
id rode a warm entry and could write usage events into another tenant's meters.

Reuses the shared credential fingerprint, which now takes a plain Uuid so both
binaries reach it without converting id types.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WiwhEunTESEw8qgKPHnpk
Portal invoice payment and every checkout path took payment_method_id straight
from the request and resolved it with a tenant-only lookup, so any customer of
a tenant could charge another customer's stored card while settling their own
invoice.

Adds get_by_id_for_customer and puts the payer in the signatures of
create_payment_intent and charge_payment_method_directly, so a caller cannot
omit it rather than relying on a check per call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WiwhEunTESEw8qgKPHnpk
process_payment_intent_events ignored its connector argument and resolved the
transaction from provider metadata, so a tenant could settle another tenant's
invoice with an event signed by their own webhook secret. The setup-intent
handler had the same shape, trusting a metadata customer id.

Payment intents now must name the signing connector's tenant and must match the
transaction's amount and currency. Setup intents resolve the connection under
that tenant and take the customer from the row instead of the metadata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WiwhEunTESEw8qgKPHnpk
CouponStatusRowPatch::patch matched on id alone, so any tenant could archive,
disable, or re-enable another tenant's coupon and read it back from the
RETURNING clause. tenant_id is declared part of the primary key, so AsChangeset
drops it from the statement and the struct field looked like it was doing work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WiwhEunTESEw8qgKPHnpk
The public inbound webhook route skipped signature verification entirely when a
connector had no sensitive data, and connect_stripe stored whatever
webhook_secret it was given. Only the browser form required a non-empty value,
so a connector created over gRPC could leave the route forgeable by anyone.

The route now rejects a connector without usable Stripe secret material,
verify_webhook refuses an empty key rather than HMAC-ing with it, and
Store::connect_stripe requires a whsec_ secret. The webhook test signs its
payload and asserts an unsigned delivery is rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WiwhEunTESEw8qgKPHnpk
h2 0.4.14 lets an unauthenticated peer hold a connection open with unbounded
empty DATA frames (RUSTSEC-2026-0258); it serves the public gRPC listeners via
tonic. Bumped to 0.4.18.

Also silences RUSTSEC-2026-0235: rkyv reaches Cargo.lock only through an
optional rust_decimal feature that nothing here enables, so it is never built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WiwhEunTESEw8qgKPHnpk
@nwilkens nwilkens changed the title Security hardening: auth, secrets at rest, payments, webhooks fix: harden auth, secrets at rest, payments, and webhooks Aug 21, 2026
nwilkens and others added 2 commits August 20, 2026 21:04
Binding the charged payment method to the payer added an 8th argument, one past
the clippy threshold. Matches how the rest of this crate handles the lint rather
than wrapping a single-call-site private helper in a struct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WiwhEunTESEw8qgKPHnpk
The step hard-failed on every run in this fork because DOCKER_HUB_USERNAME and
DOCKER_HUB_PASSWORD are not set here, and it skipped the test step with it, so
no PR was ever actually tested. It only existed to pull testcontainers images
authenticated; anonymous pulls work, subject to Docker Hub rate limits.

docker.yaml keeps its logins for publishing, already guarded by a -n check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WiwhEunTESEw8qgKPHnpk
@nwilkens
nwilkens merged commit e8055ea into triton-main Aug 21, 2026
7 checks passed
@nwilkens
nwilkens deleted the security-fixes branch August 21, 2026 12:16
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.

1 participant