e4ce0773cbbc

add zxcvbn strength gate (score ≥ 3) to PeachScript backup password export — prevents weak backup passwords

time
2026-05-17 13:36 UTC · 5 months ago
machine
koh2
parent
98fd3c5d6af4
2 files src/popup/styles/popup.css+26 −0src/popup/views/Export.tsx+57 −9
src/popup/styles/popup.css+26 −0
@@ -1714,6 +1714,32 @@17141714   line-height: 1.5;17151715   color: var(--peach-t2);17161716 }1717+.peach-export-strength-track {1718+  height: 3px;1719+  background: var(--peach-border);1720+  border-radius: 2px;1721+  margin: 4px 0 2px;1722+  overflow: hidden;1723+}1724+.peach-export-strength-fill {1725+  height: 100%;1726+  border-radius: 2px;1727+  transition: width 250ms ease, background 250ms ease;1728+}1729+.peach-export-strength-warn {1730+  margin: 0;1731+  font-size: 0.625rem;1732+  line-height: 1.5;1733+  color: #e84040;1734+  font-family: "IBM Plex Mono", monospace;1735+}1736+.peach-export-strength-hint {1737+  margin: 0;1738+  font-size: 0.625rem;1739+  line-height: 1.5;1740+  color: var(--peach-t2);1741+  font-family: "IBM Plex Mono", monospace;1742+}17171743 .peach-export-pill {17181744   display: inline-flex;17191745   align-items: center;

view file

src/popup/views/Export.tsx+57 −9
@@ -16,8 +16,23 @@1616   onBack: () => void;1717 }1818 19-const MIN_BACKUP_PASSWORD = 8;19+// Lazy-load zxcvbn — it's heavy (~800 kB) and only needed when the user20+// starts typing a backup password. Shared module-level cache so it is only21+// fetched once across multiple PeachScriptExport mounts.22+type ZxcvbnFn = (pw: string) => { score: number; feedback: { warning: string; suggestions: string[] } };23+let _zxcvbn: ZxcvbnFn | null = null;24+async function getZxcvbn(): Promise<ZxcvbnFn> {25+  if (_zxcvbn) return _zxcvbn;26+  const mod = await import("zxcvbn");27+  _zxcvbn = mod.default as ZxcvbnFn;28+  return _zxcvbn;29+}2030 31+// Backup password must score at least 3 (strong) on the zxcvbn scale (0–4).32+// This prevents a targeted wordlist attack from cracking a weak backup in33+// seconds even though Argon2id raises the per-guess cost.34+const STRENGTH_REQUIRED = 3;35+2136 export function ExportView(props: ExportProps) {2237   const [view, setView] = createSignal<ExportSubview>("menu");2338   return (@@ -83,6 +98,8 @@8398 function PeachScriptExport(props: { onBack: () => void }) {8499   const [pass, setPass] = createSignal("");85100   const [confirm, setConfirm] = createSignal("");101+  const [strengthScore, setStrengthScore] = createSignal<number>(0);102+  const [strengthHint, setStrengthHint] = createSignal<string>("");86103   const [working, setWorking] = createSignal(false);87104   const [script, setScript] = createSignal<string | null>(null);88105   const [error, setError] = createSignal<string | null>(null);@@ -96,9 +113,26 @@96113   const [copied, setCopied] = createSignal(false);97114 98115   const passReady = createMemo(99-    () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()116+    () => strengthScore() >= STRENGTH_REQUIRED && pass() === confirm()100117   );101118 119+  function handlePassInput(value: string) {120+    setPass(value);121+    if (!value) {122+      setStrengthScore(0);123+      setStrengthHint("");124+      return;125+    }126+    // Fire-and-forget: if zxcvbn hasn't loaded yet the signal updates when it does127+    void getZxcvbn().then((zxcvbn) => {128+      if (pass() !== value) return; // stale — user typed ahead129+      const result = zxcvbn(value);130+      setStrengthScore(result.score);131+      const hint = result.feedback.warning || result.feedback.suggestions[0] || "";132+      setStrengthHint(hint);133+    });134+  }135+102136   async function handleEncode() {103137     setError(null);104138     setScript(null);@@ -170,9 +204,9 @@170204                   ref={(el) => queueMicrotask(() => el.focus())}171205                   class="peach-settings-input"172206                   type="password"173-                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}207+                  placeholder="backup password"174208                   value={pass()}175-                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}209+                  onInput={(e) => handlePassInput((e.target as HTMLInputElement).value)}176210                   onKeyDown={(e) => {177211                     if (e.key === "Enter" && passReady() && !working()) {178212                       e.preventDefault();@@ -180,6 +214,25 @@180214                     }181215                   }}182216                 />217+                <Show when={pass().length > 0}>218+                  <div class="peach-export-strength-track">219+                    <div220+                      class="peach-export-strength-fill"221+                      style={{222+                        width: `${(strengthScore() / 4) * 100}%`,223+                        background: ["transparent","#e84040","#e8a040","#40a840","#40c840"][strengthScore()],224+                      }}225+                    />226+                  </div>227+                  <Show when={strengthScore() < STRENGTH_REQUIRED}>228+                    <p class="peach-export-strength-warn">229+                      {strengthHint() || "Choose a stronger backup password. This protects your vault if the file is ever found."}230+                    </p>231+                  </Show>232+                  <Show when={strengthScore() >= STRENGTH_REQUIRED && strengthHint()}>233+                    <p class="peach-export-strength-hint">{strengthHint()}</p>234+                  </Show>235+                </Show>183236                 <input184237                   class="peach-settings-input"185238                   type="password"@@ -196,11 +249,6 @@196249                 <Show when={confirm().length > 0 && pass() !== confirm()}>197250                   <p class="peach-export-note" style="color:var(--peach-t2)">198251                     Passwords don't match199-                  </p>200-                </Show>201-                <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>202-                  <p class="peach-export-note" style="color:var(--peach-t2)">203-                    At least {MIN_BACKUP_PASSWORD} characters204252                   </p>205253                 </Show>206254               </div>

view file