Skip to content

Commit f40e3b1

Browse files
axpnetclaude
andcommitted
fix(connection): migrate legacy per-mode credential keys and clamp the 2FA paste to six digits
Two findings from the pre-tag commit audit of the Quick Connect batch. Per-mode credential snapshots persisted by earlier versions still use the pre-rekey providerId-or-protocol map keys, so after the collision-free rekey they restored blank on the first mode switch and lingered as dead secret entries; the hydrate path now re-keys legacy entries to the new form. And the six-digit clamp on the 2FA code fields moves from the maxLength attribute into the shared handler: the DOM truncates a paste before the change event, so pasting a code with separators (for example "123 456") silently lost the sixth digit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent aa28945 commit f40e3b1

1 file changed

Lines changed: 34 additions & 6 deletions

File tree

src/components/ConnectionScreen.tsx

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,31 @@ export const ConnectionScreen: React.FC<ConnectionScreenProps> = ({
905905
return `${pid}|${proto}`;
906906
};
907907

908+
// Legacy persisted snapshots (pre collision-free keys) used
909+
// `providerId || protocol` as the map key. Re-key them to the current
910+
// `pid|proto` form at hydrate time, so per-mode credentials saved before
911+
// the rekey keep restoring instead of coming up blank, and the old-format
912+
// entries do not linger in the vault map as dead secrets. For the one
913+
// historic collision (Koofr: both modes hashed to 'koofr') the surviving
914+
// entry deterministically lands on the group's first matching mode.
915+
const migrateLegacyModeKeys = (
916+
persisted: ModeCredentialMap,
917+
group: ReturnType<typeof findActiveModeGroup>
918+
): ModeCredentialMap => {
919+
if (!group) return persisted;
920+
const out: ModeCredentialMap = { ...persisted };
921+
for (const mode of group.modes) {
922+
const proto = mode.protocol as string;
923+
const legacyKey = mode.providerId || proto;
924+
const newKey = modeStashKey(mode.providerId, proto);
925+
if (legacyKey !== newKey && out[legacyKey] && !out[newKey]) {
926+
out[newKey] = out[legacyKey];
927+
delete out[legacyKey];
928+
}
929+
}
930+
return out;
931+
};
932+
908933
// The stash key of the currently-active mode, matching the oldKey/newKey
909934
// convention in handleProtocolChange.
910935
const computeActiveModeKey = (): string => {
@@ -1895,7 +1920,10 @@ export const ConnectionScreen: React.FC<ConnectionScreenProps> = ({
18951920
try {
18961921
const persisted = await loadModeCredentials(targetProfileId);
18971922
if (editingProfileIdRef.current === targetProfileId) {
1898-
modeCredentialSnapshotsRef.current = persisted;
1923+
// Re-key any legacy-format snapshot so pre-rekey saves
1924+
// still restore on the first mode switch.
1925+
const group = findActiveModeGroup(profile.providerId, profile.protocol);
1926+
modeCredentialSnapshotsRef.current = migrateLegacyModeKeys(persisted, group);
18991927
}
19001928
} catch {
19011929
// No persisted modes: in-session snapshots only.
@@ -2336,9 +2364,12 @@ export const ConnectionScreen: React.FC<ConnectionScreenProps> = ({
23362364

23372365
// #369: the 2FA code fields (Filen / MEGA / Internxt) accept digits only,
23382366
// so a stray letter or space can never make a TOTP silently wrong. Shared
2339-
// by all three forms so the behaviour stays identical.
2367+
// by all three forms so the behaviour stays identical. The 6-digit clamp
2368+
// lives HERE, not in a maxLength attribute: the DOM truncates a paste
2369+
// BEFORE the change event, so "123 456" under maxLength arrived as
2370+
// "123 45" and stripped to five digits, silently corrupting the code.
23402371
const handleTotpCodeChange = (raw: string) => {
2341-
const digits = raw.replace(/\D/g, '');
2372+
const digits = raw.replace(/\D/g, '').slice(0, 6);
23422373
onConnectionParamsChange({
23432374
...connectionParams,
23442375
options: { ...connectionParams.options, two_factor_code: digits || undefined },
@@ -4566,7 +4597,6 @@ export const ConnectionScreen: React.FC<ConnectionScreenProps> = ({
45664597
onChange={(e) => handleTotpCodeChange(e.target.value)}
45674598
className="w-32 px-4 py-2.5 text-center tracking-[0.3em] font-mono bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
45684599
placeholder="000000"
4569-
maxLength={6}
45704600
inputMode="numeric"
45714601
autoComplete="one-time-code"
45724602
/>
@@ -4732,7 +4762,6 @@ export const ConnectionScreen: React.FC<ConnectionScreenProps> = ({
47324762
onChange={(e) => handleTotpCodeChange(e.target.value)}
47334763
className="w-32 px-4 py-2.5 text-center tracking-[0.3em] font-mono bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
47344764
placeholder="000000"
4735-
maxLength={6}
47364765
inputMode="numeric"
47374766
autoComplete="one-time-code"
47384767
/>
@@ -5149,7 +5178,6 @@ export const ConnectionScreen: React.FC<ConnectionScreenProps> = ({
51495178
onChange={(e) => handleTotpCodeChange(e.target.value)}
51505179
className="w-32 px-4 py-2.5 text-center tracking-[0.3em] font-mono bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500"
51515180
placeholder="000000"
5152-
maxLength={6}
51535181
inputMode="numeric"
51545182
autoComplete="one-time-code"
51555183
/>

0 commit comments

Comments
 (0)