Skip to content

feat(web): support separate decryption keys - #139

Open
snowyukitty wants to merge 1 commit into
osbytes:mainfrom
snowyukitty:feat/separate-decryption-key
Open

feat(web): support separate decryption keys#139
snowyukitty wants to merge 1 commit into
osbytes:mainfrom
snowyukitty:feat/separate-decryption-key

Conversation

@snowyukitty

Copy link
Copy Markdown

Summary

  • Add an opt-in VITE_SEPARATE_DECRYPTION_KEY setting, with Docker support, while preserving fragment links by default.
  • Show the URL and key separately, keep every QR code key-free, and provide an accessible manual key form.
  • Keep current #key links working and safely scrub deprecated ?key= links before router initialization.
  • Document the security model, update all existing locales, and add focused regression tests.

Fixes #54

Security

  • Raw keys stay out of generated query/path URLs, QR payloads and filenames, React Query data and cache identities, logs, and generic errors.
  • Necessary client copies live only in short-lived refs; manual DOM input is cleared before handoff and refs are dropped after use.
  • Secret-view state is remounted on vault, password-mode, router-navigation, and native-fragment changes so a prior key cannot be reused across views.
  • Combined fragment links remain supported as an explicit compatibility mode; separated mode is opt-in and should use an out-of-band key channel.

Testing

  • pnpm --filter @crypt.fyi/web test — 6 files, 22 tests passed
  • pnpm --filter @crypt.fyi/web lint
  • pnpm typecheck
  • Default and VITE_SEPARATE_DECRYPTION_KEY=true production web builds
  • Core tests — 7 suites, 56 tests passed
  • CLI tests — 1 suite, 15 tests passed
  • Exact final patch: independent packet-only security and threat-model review, no material findings

Server integration tests were attempted but could not connect to Redis on this machine; no server source changed.

AI assistance

AI assistance: OpenAI Codex assisted with implementation and review. The final patch and test evidence were reviewed by the submitter, and the exact candidate also received an independent packet-only security and threat-model review with Grok 4.5 (no material findings).

@dillonstreator dillonstreator left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the thorough work here — the receiver-side flow (missing key → manual entry prompt) is exactly the right shape, and keeping the raw key out of React Query cache identities is a good catch. However, I'd like to take a different overall approach, which should significantly shrink this PR:

1. No configuration — always show both options

I don't want this to be deployment/config-driven. Instead, the create-success view should always show three fields:

  1. The combined URL (key in fragment) — current behavior
  2. The key-free URL
  3. The decryption key on its own

The sender chooses how to share at share time, rather than the deployer choosing at build time. A URL without a key already implicitly tells the recipient to enter it separately — the key prompt you built renders whenever the fragment is missing, so no query parameter and no flag are needed. This removes VITE_SEPARATE_DECRYPTION_KEY and with it the Dockerfile.web, docker-compose.yaml, README, config.ts, and config.test.ts changes.

2. Hard-break ?key= support instead of the compatibility layer

Rather than carrying the deprecated query-param format forward, let's remove support for it entirely (planned as its own release/changeset for changelog clarity — happy to coordinate sequencing). Rationale: secrets are TTL-bound so there's no long tail of old links, and a ?key= link has already leaked the key to server logs by the time the client sees it — reading it client-side only masks that. With the new key prompt, an old ?key= link degrades gracefully: the recipient lands on the prompt and can still enter the key manually.

That means legacyKeyBootstrap.ts and the legacy branches in resolveDecryptionKey can go. The one piece worth keeping is a small scrub: if a key query param is present, history.replaceState it away and show a warning toast that the link format is unsafe — without ever reading the value into app state. That's a few lines in main.tsx, not a module.

3. Please trim the unrelated changes

A few things in here are separate concerns that make the diff hard to review — details inline, but broadly: the clipboardCopy rework, the router remount machinery, and the generic createFailed error handling. Happy to see any of those as their own PRs with their own rationale. (Rewording existing locale strings is fine where this feature changes their meaning — e.g. success.description.main must change once auto-copy is removed — just avoid general copy edits beyond that.)

One change I do want to keep from your approach: dropping the automatic copy-to-clipboard on create. Letting the user explicitly choose what to copy is safer and fits the three-field design.

Core of what survives: buildSecretLinks (simplified to always return both URLs), the DecryptionKeyPrompt component and missing-key flow in View.tsx, the new locale keys, and keeping the key out of mutation cache identity. That's a much smaller, focused PR.

@@ -1,5 +1,6 @@
export const config = Object.freeze({
API_URL: import.meta.env.VITE_API_URL ?? 'http://localhost:4321',
SEPARATE_DECRYPTION_KEY: import.meta.env.VITE_SEPARATE_DECRYPTION_KEY === 'true',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Drop — per the top-level comment, this shouldn't be config-driven. The success view will always show both URL forms plus the key. That removes this flag along with config.test.ts, the Dockerfile.web/docker-compose.yaml build args, and the README configuration section.

userInitiatedFallback?: boolean;
}

export const clipboardCopy = async (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please revert this file (and its tests). With auto-copy removed entirely, every remaining call site is a user-initiated button click, so the userInitiatedFallback option has no caller that needs it. This was only ever a call-site concern.

@@ -0,0 +1,101 @@
import { stripLegacyKeyFromUrl } from './secretUrl';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Remove — we're going to hard-break ?key= support instead (see top-level comment). The module-level import side effect and the setTimeout(0) capture-replay window are a lot of machinery to keep a format alive that already leaked the key by the time it reaches the client. Replace with a small scrub in main.tsx: if key is in the query string, replaceState it away and toast a warning — never read the value.

const { id } = useParams({ from: '/$id' });
const search = useSearch({ from: '/$id' });
const navigationKey = useRouterState({
select: (state) => state.location.state.__TSR_key,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

state.location.state.__TSR_key is a private TanStack Router internal and will break on upgrade. With the legacy path gone I don't think the remount machinery (remountDeps + hashchange listener + composite instance key) is needed — ViewPage can read the hash on mount as before. If there's a concrete stale-key-across-navigation repro this is solving, let's look at that case specifically.

* query parameter. QR codes intentionally remain key-free in both modes
* because they are commonly downloaded, screenshotted, and retained.
*/
export function buildSecretLinks({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Drop the separateDecryptionKey flag; return both forms unconditionally, e.g. { combinedUrl, keylessUrl, qrUrl }. The resolveDecryptionKey legacy-query branches can also go with the ?key= hard break — resolution reduces to "fragment or manual entry."

onError(error) {
toast.error(error.message);
onError() {
toast.error(t('create.errors.createFailed'));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please keep toast.error(error.message) — swapping in a generic createFailed message loses actionable error info and is out of scope here (drops the new locale key too).

<QRCodeSVG
ref={qrCodeRef}
value={createMutation.data.url}
value={createMutation.data.qrUrl}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Making the QR key-free unconditionally is a real behavior change for existing users — scanning is no longer sufficient to open a secret. I'd keep the QR encoding the combined URL for now; if we want a key-free QR option it should be its own discussion. (Open to revisiting once the three-field UI exists.)

description: {
main: 'Your secret has been created and the URL has been copied to your clipboard',
password: 'Share the URL and password with the desired recipient',
main: 'Your secret has been created. Share the URL below with the intended recipient.',

@dillonstreator dillonstreator Jul 30, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

On the locale changes: rewording existing strings is fine where this feature actually changes behavior — e.g. success.description.main has to change since we are dropping the automatic clipboard copy, and success.description.password reads better with the new share flow. Just keep rewording limited to strings whose meaning is affected by this feature (rather than general copy edits), so the five-locale diff stays reviewable.

onChange={(e) => {
setPassword(e.target.value);
onChange={(event) => {
passwordRef.current = event.target.value;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The controlled→ref conversion of the password input (plus the eevent renames) is churn unrelated to this feature — please revert to keep the diff focused. The a11y additions (labels, aria-describedby, role="alert") are welcome though.

@@ -0,0 +1,131 @@
import { renderToStaticMarkup } from 'react-dom/server';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: string-matching renderToStaticMarkup output with every dependency mocked is brittle. Prefer @testing-library/react queries against rendered behavior. Fine as a follow-up.

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.

add configuration to remove key from url hash

2 participants