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
↓ peach-browser-07ce1ae7.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/views/RecoveryWordDisplay.tsx+49 −22src/shared/config.ts+11 −1manifest.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": {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",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": {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();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';