Skip to content

feat(hub): secret intake links — secure secret submission from chat#318

Open
zeroasterisk wants to merge 5 commits into
GoogleCloudPlatform:mainfrom
zeroasterisk:feat/secret-intake-link
Open

feat(hub): secret intake links — secure secret submission from chat#318
zeroasterisk wants to merge 5 commits into
GoogleCloudPlatform:mainfrom
zeroasterisk:feat/secret-intake-link

Conversation

@zeroasterisk

Copy link
Copy Markdown
Contributor

Why

When users interact with Scion agents via chat (Telegram, Discord), agents sometimes need secrets. Today users must leave the chat context to use CLI/web UI, or paste secrets in plaintext — a security anti-pattern that happened in the project's own history (raw GitHub PAT pasted into Telegram).

What

Secret intake links: a short-lived, JWT-secured URL that lets users paste a secret value directly into the Hub. The secret is stored as pending until the user confirms in the originating chat channel.

User flow

Agent → "I need your GitHub token: https://hub.example.com/intake#<JWT>"
User  → clicks link, pastes secret value
Hub   → stores as PENDING, sends to chat:
        "🔐 GITHUB_TOKEN submitted from Chrome/Mac at 203.0.113.42. Confirm?"
User  → confirms in chat
Hub   → secret becomes ACTIVE, available to agents

Security model

Protection How
One-time use Intake consumed after submission
Short-lived 15 min TTL default, 1 hour max
Rate limited 5 attempts per intake + per-IP token bucket
JWT signed HS256 with Hub signing key, verified server-side
Pending gate Value not usable until creator confirms in authenticated chat
Tamper evident Submitter IP/UA shown in confirmation message
Zero-knowledge URL JWT in fragment — never sent to server in HTTP request

How

New files (1,859 lines)

  • pkg/hub/secret_intake.go — SecretIntakeService (in-memory, follows TelegramLinkService pattern), handlers for create/submit/confirm/reject, JWT generation/validation, rate limiting
  • pkg/hub/secret_intake_test.go — 12 tests covering full lifecycle + security scenarios
  • pkg/hubclient/secret_intake.go — SDK client for the intake API
  • cmd/hub_secret_intake.goscion hub secret intake KEY CLI command
  • web/src/components/pages/secret-intake.ts — Lit component intake page (no login required)
  • .design/secret-intake-link.md — Full design document with security analysis

Modified files

  • pkg/hub/server.go — route registration + service lifecycle
  • pkg/hub/auth.go — exempt intake submit endpoint from auth (JWT is the auth)
  • pkg/hubclient/client.go — SecretIntake service accessor
  • web/src/client/main.ts — register /intake page route

Test plan

  • go build ./... passes
  • 12 tests: create, submit, confirm, reject, expired, consumed, rate limiting, tampered JWT, unauthorized confirm, auth required, missing key, method not allowed
  • All existing tests unaffected

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request implements Secret Intake Links, enabling secure secret submission from chat channels via short-lived, one-time-use URLs. It adds a CLI command, backend handlers, an in-memory SecretIntakeService, and a standalone frontend page for anonymous submission. The review feedback identifies several critical issues: a concurrency data race on SecretIntake fields between handlers and the background cleanup loop; a client-side JWT decoding bug due to missing base64 padding and lack of UTF-8 support; a potential memory-exhaustion DoS vulnerability from a missing size limit on submitted values; a validation gap where scope_id is not verified for project-scoped secrets; loose path matching in the unauthenticated endpoint check; and a bug where trimming whitespace from submitted secrets can corrupt keys with significant whitespace.

Comment thread pkg/hub/secret_intake.go Outdated
Comment thread web/src/components/pages/secret-intake.ts Outdated
Comment thread pkg/hub/secret_intake.go
Comment thread pkg/hub/secret_intake.go
Comment thread pkg/hub/auth.go Outdated
Comment thread web/src/components/pages/secret-intake.ts
@ptone

ptone commented Jun 5, 2026

Copy link
Copy Markdown
Member

could said link basically just bring you to the new secret dialog in the linked project

the secret intake with no login, then confirmation in chat seems like a mild security risk surface

@zeroasterisk

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback! We were going for a more versatile and zero-friction approach (works even if the user isn't near a browser session), but you're right that the no-login surface is unnecessary risk. Switching to a deep-link into the Hub UI with a simplified, focused secret-entry screen. The user must be logged in, the link pre-fills the key/scope/project/type from a JWT, and we'll send a notification to chat when the secret is stored. Dropping the anonymous intake page and the confirmation step.

@zeroasterisk zeroasterisk force-pushed the feat/secret-intake-link branch from 3e3da3f to 9cbfdc9 Compare June 7, 2026 22:53
@zeroasterisk

Copy link
Copy Markdown
Contributor Author

Round 3 review complete — 3 clean cycles, no new findings. Rebased on upstream/main (resolved 4 merge conflicts in server.go from upstream Discord link service additions). All tests pass. Design simplified per maintainer feedback: authenticated deep-link, no anonymous endpoints, no OTP/confirmation flow. 12 tests.

…chat

Agents in chat channels (Telegram, Discord) sometimes need users to
provide secrets (API keys, tokens). Today users must either leave the
chat to use the CLI/web UI, or paste secrets in plaintext in chat
messages — a security anti-pattern.

Secret intake links solve this: a short-lived JWT-secured URL that
lets users paste a secret value directly into the Hub. The secret is
stored in a pending state until the user confirms via the originating
chat channel, where they can verify the submitter's IP and user-agent.

Flow:
1. Agent calls POST /api/v1/secret-intake with key, scope, type
2. Hub returns a URL with JWT in fragment (never sent to server)
3. Agent sends URL in chat
4. User clicks, pastes value → stored as PENDING
5. Confirmation sent to chat with submitter IP/UA
6. User confirms → secret becomes ACTIVE

Security: one-time use, 15 min TTL, rate limiting (5 attempts per
intake + per-IP bucket), JWT signed with Hub signing key, pending
value cleared on confirm/reject/expire. No login required on the
intake page — the JWT is the authorization, confirmation is the gate.

New files:
- pkg/hub/secret_intake.go (716 lines) — handlers + service
- pkg/hub/secret_intake_test.go (472 lines) — 12 tests
- pkg/hubclient/secret_intake.go — SDK client
- cmd/hub_secret_intake.go — CLI command
- web/src/components/pages/secret-intake.ts — intake page
- .design/secret-intake-link.md — design document

Design doc: .design/secret-intake-link.md
Review findings and fixes:

1. TOCTOU race in submit handler: GetIntake + check status + update
   was not atomic. Concurrent submitters could both pass validation.
   Fix: new TrySubmit() method does all checks under a single lock.

2. GetIntake returned pointer to shared map entry. Callers could
   mutate the live record without holding the lock.
   Fix: GetIntake now returns a defensive copy.

3. Added 6 new tests:
   - TokenMismatch: JWT from one intake used on a different intake ID
   - DoubleConfirm: confirm an already-confirmed intake
   - SubmitMissingFields: missing token or value in submit body
   - RejectWrongStatus: reject intake that isn't pending confirmation
   - TTLCapping: TTL > 1 hour gets capped
   - ConfirmWrongStatus: confirm intake that isn't pending confirmation

Total: 18 tests, all pass.
Redesign per maintainer feedback: replace the anonymous no-login
intake page with an authenticated deep-link into the Hub web UI.

Changes:
- User MUST be logged in to submit a secret value (no anonymous endpoint)
- Removed: pending/confirmation flow, OTP, PendingValue, SubmitterIP/UA,
  per-intake rate limiting, TrySubmit atomics
- Simplified: intake record is just metadata + consumed flag
- Secret is stored immediately on submit via secretBackend.Set()
- Notification sent to originating chat channel on success
- Auth exemption for submit endpoint removed from auth.go
- Web intake page requires login, shows focused single-purpose form
- Design doc updated to reflect simplified architecture

Net: -450 lines. 12 tests covering the simplified flow.
…d validation, body limit

Fixes from Gemini Code Assist review:
1. JWT base64url decode: add padding for atob + UTF-8 decode via escape/decodeURIComponent
2. scope_id validation: reject project-scoped intakes without scope_id
3. Body size limit: MaxBytesReader(128KiB) on store endpoint to prevent memory exhaustion
4. Don't trim secret values: whitespace may be significant in some secrets
@zeroasterisk zeroasterisk force-pushed the feat/secret-intake-link branch from 8d1bf6c to 495021e Compare June 17, 2026 03:49
@zeroasterisk

Copy link
Copy Markdown
Contributor Author

could said link basically just bring you to the new secret dialog in the linked project

the secret intake with no login, then confirmation in chat seems like a mild security risk surface

This is what we did

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