07ce1ae7d42c

fix(security): recovery words no longer exported to world-readable Android public Downloads — native offers a clipboard copy, desktop keeps user-chosen-location .txt (MEDIUM #7); document Web3Forms key as an intentionally-public anti-spam token (INFO #11) (v3.6.129)

time
2026-05-28 22:46 UTC · 5 months ago
machine
koh2
parent
cf426553736e
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/views/RecoveryWordDisplay.tsx+49 −22src/shared/config.ts+11 −1
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "3.6.128",5+  "version": "3.6.129",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "3.6.128",4+  "version": "3.6.129",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.128",3+  "version": "3.6.129",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/views/RecoveryWordDisplay.tsx+49 −22
@@ -20,6 +20,17 @@2020   const [confirmed, setConfirmed] = createSignal(false);2121   // 5-second countdown before confirmation input is enabled2222   const [countdown, setCountdown] = createSignal(5);23+  const [copied, setCopied] = createSignal(false);24+25+  async function copyRecoveryWords() {26+    try {27+      await navigator.clipboard.writeText(recoveryWordsText(words()));28+      setCopied(true);29+      setTimeout(() => setCopied(false), 1800);30+    } catch {31+      /* clipboard denied — the words remain on screen for transcription */32+    }33+  }2334   let countdownTimer: ReturnType<typeof setInterval> | null = null;2435 2536   onMount(async () => {@@ -122,13 +133,31 @@122133               )}123134             </For>124135           </ol>125-          <button126-            type="button"127-            class="peach-recovery-download-btn"128-            onClick={() => void saveRecoveryWords(words())}136+          {/* On native we must NOT write the master recovery secret to a file:137+              the Android download sink lands in world-readable public Downloads138+              (audit MEDIUM). Offer a transient clipboard copy the user routes139+              into their own secure store instead. Desktop keeps the140+              user-chosen-location .txt download. */}141+          <Show142+            when={isNativePlatform()}143+            fallback={144+              <button145+                type="button"146+                class="peach-recovery-download-btn"147+                onClick={() => void saveRecoveryWords(words())}148+              >149+                download as .txt150+              </button>151+            }129152           >130-            download as .txt131-          </button>153+            <button154+              type="button"155+              class="peach-recovery-download-btn"156+              onClick={() => void copyRecoveryWords()}157+            >158+              {copied() ? "copied ✓ — paste somewhere safe" : "copy words"}159+            </button>160+          </Show>132161         </div>133162 134163         {/* Confirmation step */}@@ -192,29 +221,27 @@192221   );193222 }194223 195-function saveRecoveryWords(words: string[]): void {224+function recoveryWordsText(words: string[]): string {196225   const numbered = words.map((w, i) => `${String(i + 1).padStart(2, " ")}. ${w}`).join("\n");197-  const body =226+  return (198227     "PEACH RECOVERY WORDS\n" +199228     "====================\n\n" +200229     "Anyone with these 12 words can recover your vault.\n" +201-    "Store this file somewhere only you can access.\n\n" +202-    numbered + "\n";230+    "Store this somewhere only you can access.\n\n" +231+    numbered + "\n"232+  );233+}203234 235+// Desktop only — writes via a blob to a user-chosen save location. The native236+// path deliberately does NOT use a file download: on Android the data:-URL237+// download sink writes to world-readable public Downloads (audit MEDIUM), so238+// native offers a clipboard copy (copyRecoveryWords) the user routes instead.239+function saveRecoveryWords(words: string[]): void {240+  const blob = new Blob([recoveryWordsText(words)], { type: "text/plain" });204241   const a = document.createElement("a");205242   a.download = "peach-recovery-words.txt";206-  if (isNativePlatform()) {207-    // Capacitor WebView: blob: URLs are opaque to native, but data: URLs go208-    // through MainActivity.setDownloadListener which writes them to public209-    // Downloads via MediaStore. Encode as base64 to keep the data URL210-    // intact through the WebView's URL plumbing.211-    const b64 = btoa(unescape(encodeURIComponent(body)));212-    a.href = `data:text/plain;base64,${b64}`;213-  } else {214-    const blob = new Blob([body], { type: "text/plain" });215-    a.href = URL.createObjectURL(blob);216-    setTimeout(() => URL.revokeObjectURL(a.href), 1000);217-  }243+  a.href = URL.createObjectURL(blob);244+  setTimeout(() => URL.revokeObjectURL(a.href), 1000);218245   document.body.appendChild(a);219246   a.click();220247   a.remove();

view file

src/shared/config.ts+11 −1
@@ -1,2 +1,12 @@1-// Temporary until Vesper (internal tracker) is ready1+// Web3Forms access key for the in-app feedback form (Settings → feedback).2+//3+// SECURITY NOTE (audit INFO): this key ships in the public extension bundle by4+// design — Web3Forms "access keys" are client-side, write-only form-submission5+// identifiers, not secrets. The feedback POST carries only the category + the6+// user-typed message (sanitized) + a static from_name; NO vault data, keys, or7+// credentials are ever included, and the key cannot read prior submissions or8+// access anything. The only abuse it enables is spamming Peach's own feedback9+// inbox. It is therefore safe to ship, unlike a cryptographic key. If inbox10+// spam becomes a problem, proxy feedback through a rate-limited backend11+// (tracked as "Vesper") and rotate this token.212 export const WEB3FORMS_KEY = '5707c8d7-d85a-4edd-9b66-95da8f25557e';

view file