From 4c97e22f3aa912b3acbe4a52189e0cdf909688c9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 30 May 2026 19:45:56 +0200 Subject: [PATCH 1/7] Bound pwsafe stretch iterations --- pwsafe/db.go | 17 ++++++++++++++++- pwsafe/decrypt.go | 3 +++ pwsafe/decrypt_test.go | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/pwsafe/db.go b/pwsafe/db.go index 64cc7d3..776c206 100644 --- a/pwsafe/db.go +++ b/pwsafe/db.go @@ -30,6 +30,11 @@ type V3 struct { StretchedKey [sha256.Size]byte } +const ( + defaultStretchIterations uint32 = 262144 + maxStretchIterations uint32 = 10_000_000 +) + // NewV3 - create and initialize a new pwsafe.V3 db func NewV3(name, password string) *V3 { var db V3 @@ -167,7 +172,7 @@ func (db V3) Search(query string, mode int) []string { // SetPassword Sets the password that will be used to encrypt the file on next save func (db *V3) SetPassword(pw string) error { // First recalculate the Salt and set iter - db.Iter = 262144 + db.Iter = defaultStretchIterations if _, err := rand.Read(db.Salt[:]); err != nil { return err } @@ -176,6 +181,13 @@ func (db *V3) SetPassword(pw string) error { return nil } +func validateStretchIterations(iter uint32) error { + if iter == 0 || iter > maxStretchIterations { + return fmt.Errorf("invalid stretch iteration count %d", iter) + } + return nil +} + // SetRecord Adds or updates a record in the db, keyed by UUID. Returns the UUID hex key. func (db *V3) SetRecord(record Record) string { now := time.Now() @@ -205,6 +217,9 @@ func (db *V3) calculateHMAC(unencrypted []byte) { // calculateStretchKey Using the db Salt and Iter along with the passwd calculate the stretch key func (db *V3) calculateStretchKey(passwd string) { + if err := validateStretchIterations(db.Iter); err != nil { + panic(err) + } iterations := int(db.Iter) salted := append([]byte(passwd), db.Salt[:]...) defer func() { diff --git a/pwsafe/decrypt.go b/pwsafe/decrypt.go index 18d1a5f..0b391b6 100644 --- a/pwsafe/decrypt.go +++ b/pwsafe/decrypt.go @@ -35,6 +35,9 @@ func (db *V3) Decrypt(reader io.Reader, passwd string) (int, error) { if err := binary.Read(cr, binary.LittleEndian, &db.Iter); err != nil { return cr.BytesRead, err } + if err := validateStretchIterations(db.Iter); err != nil { + return cr.BytesRead, err + } // Verify the password db.calculateStretchKey(passwd) diff --git a/pwsafe/decrypt_test.go b/pwsafe/decrypt_test.go index 8f10b7a..687c24b 100644 --- a/pwsafe/decrypt_test.go +++ b/pwsafe/decrypt_test.go @@ -1,10 +1,13 @@ package pwsafe import ( + "bytes" + "encoding/binary" "errors" "os" "path/filepath" "sort" + "strings" "testing" "time" @@ -142,6 +145,21 @@ func TestBadPassword(t *testing.T) { assert.Equal(t, err, errors.New("invalid password")) } +func TestRejectsInvalidStretchIterations(t *testing.T) { + for _, iter := range []uint32{0, maxStretchIterations + 1} { + var buf bytes.Buffer + buf.WriteString("PWS3") + buf.Write(make([]byte, 32)) + assert.NoError(t, binary.Write(&buf, binary.LittleEndian, iter)) + + var db V3 + _, err := db.Decrypt(bytes.NewReader(buf.Bytes()), "password") + if assert.Error(t, err) { + assert.True(t, strings.Contains(err.Error(), "invalid stretch iteration count")) + } + } +} + func TestRecordFieldVariations_EmptyFields(t *testing.T) { // First argument to NewV3 is path (optional, used for LastSavePath if provided), second is password. db := NewV3("", "password") // Corrected: DB password is "password" From 9e74740a2793f73ba4c6edcaa1b1cbda1b927185 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 30 May 2026 19:47:47 +0200 Subject: [PATCH 2/7] Avoid mutating db before HMAC verification --- pwsafe/decrypt.go | 34 +++++++++++++++++++--------------- pwsafe/decrypt_test.go | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/pwsafe/decrypt.go b/pwsafe/decrypt.go index 0b391b6..c1d1e25 100644 --- a/pwsafe/decrypt.go +++ b/pwsafe/decrypt.go @@ -26,26 +26,28 @@ func (db *V3) Decrypt(reader io.Reader, passwd string) (int, error) { return cr.BytesRead, errors.New("file is not a valid Password Safe v3 file") } + parsed := V3{LastSavePath: db.LastSavePath} + // Read the Salt - if _, err := io.ReadFull(cr, db.Salt[:]); err != nil { + if _, err := io.ReadFull(cr, parsed.Salt[:]); err != nil { return cr.BytesRead, err } // Read iter - if err := binary.Read(cr, binary.LittleEndian, &db.Iter); err != nil { + if err := binary.Read(cr, binary.LittleEndian, &parsed.Iter); err != nil { return cr.BytesRead, err } - if err := validateStretchIterations(db.Iter); err != nil { + if err := validateStretchIterations(parsed.Iter); err != nil { return cr.BytesRead, err } // Verify the password - db.calculateStretchKey(passwd) + parsed.calculateStretchKey(passwd) var keyHash [sha256.Size]byte if _, err := io.ReadFull(cr, keyHash[:]); err != nil { return cr.BytesRead, err } - if keyHash != sha256.Sum256(db.StretchedKey[:]) { + if keyHash != sha256.Sum256(parsed.StretchedKey[:]) { return cr.BytesRead, errors.New("invalid password") } @@ -54,9 +56,9 @@ func (db *V3) Decrypt(reader io.Reader, passwd string) (int, error) { if _, err := io.ReadFull(cr, keyData); err != nil { return cr.BytesRead, err } - db.extractKeys(keyData) + parsed.extractKeys(keyData) - if _, err := io.ReadFull(cr, db.CBCIV[:]); err != nil { + if _, err := io.ReadFull(cr, parsed.CBCIV[:]); err != nil { return cr.BytesRead, err } @@ -77,11 +79,11 @@ func (db *V3) Decrypt(reader io.Reader, passwd string) (int, error) { } } - block, err := twofish.NewCipher(db.EncryptionKey[:]) + block, err := twofish.NewCipher(parsed.EncryptionKey[:]) if err != nil { return 0, err } - decrypter := cipher.NewCBCDecrypter(block, db.CBCIV[:]) + decrypter := cipher.NewCBCDecrypter(block, parsed.CBCIV[:]) decryptedDB := make([]byte, encryptedSize) // The EOF and HMAC are after the encrypted section decrypter.CryptBlocks(decryptedDB, encryptedDB) @@ -96,25 +98,27 @@ func (db *V3) Decrypt(reader io.Reader, passwd string) (int, error) { if err != nil { return cr.BytesRead, errors.New("error parsing the unencrypted header - " + err.Error()) } - db.Header = header + parsed.Header = header - _, recordHMACData, err := db.unmarshalRecords(decryptedDB[hdrSize:]) + _, recordHMACData, err := parsed.unmarshalRecords(decryptedDB[hdrSize:]) if err != nil { return cr.BytesRead, errors.New("error parsing the unencrypted records - " + err.Error()) } hmacData := append(headerHMACData, recordHMACData...) // Verify HMAC - The HMAC is only calculated on the header/field values not length/type - db.calculateHMAC(hmacData) - if !hmac.Equal(db.HMAC[:], expectedHMAC) { + parsed.calculateHMAC(hmacData) + if !hmac.Equal(parsed.HMAC[:], expectedHMAC) { return cr.BytesRead, errors.New("error calculated HMAC does not match read HMAC") } // Ensure the DB has a UUID - if db.Header.UUID == [16]byte{} { - db.Header.UUID = [16]byte(uuid.NewRandom().Array()) + if parsed.Header.UUID == [16]byte{} { + parsed.Header.UUID = [16]byte(uuid.NewRandom().Array()) } + *db = parsed + return cr.BytesRead, nil } diff --git a/pwsafe/decrypt_test.go b/pwsafe/decrypt_test.go index 687c24b..825e3f9 100644 --- a/pwsafe/decrypt_test.go +++ b/pwsafe/decrypt_test.go @@ -37,6 +37,26 @@ func TestBadHMAC(t *testing.T) { assert.Equal(t, errors.New("error calculated HMAC does not match read HMAC"), err) } +func TestBadHMACDoesNotMutateExistingDB(t *testing.T) { + db := NewV3("sentinel", "sentinel-password") + originalHeader := db.Header + originalIter := db.Iter + originalSalt := db.Salt + originalStretchedKey := db.StretchedKey + + f, err := os.Open("./test_dbs/badHMAC.dat") + assert.NoError(t, err) + defer f.Close() + + _, err = db.Decrypt(f, "password") + assert.Equal(t, errors.New("error calculated HMAC does not match read HMAC"), err) + assert.Equal(t, originalHeader, db.Header) + assert.Equal(t, originalIter, db.Iter) + assert.Equal(t, originalSalt, db.Salt) + assert.Equal(t, originalStretchedKey, db.StretchedKey) + assert.Empty(t, db.Records) +} + func TestThreeDB(t *testing.T) { // This test relies on the password db found at ./test_db/three.dat db, err := OpenPWSafeFile("./test_dbs/three.dat", "three3#;") From ee1b8bbd05fd6cb72a432b2cd1a1ba5793fb3263 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 30 May 2026 20:22:19 +0200 Subject: [PATCH 3/7] Reduce secret retention after vault use --- SECURITY.md | 4 ++-- cmd/wasm/main.go | 11 +++++++++- pwa/src/lib/Dashboard.svelte | 39 +++++++++++++++++++++------------- pwa/src/lib/StartPage.svelte | 35 ++++++++++++++++++++++-------- pwa/src/lib/VaultSheet.svelte | 18 ++++++++++------ pwa/src/lib/secondaryVaults.js | 19 +++++++++++++++++ pwa/src/store.js | 2 +- pwsafe/db.go | 31 +++++++++++++++++++++++++++ pwsafe/db_test.go | 31 +++++++++++++++++++++++++++ 9 files changed, 156 insertions(+), 34 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 9aff5a9..7cec487 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -99,7 +99,7 @@ Both modes use the **delegate model**: each paired autofill profile has an ECDSA ## Biometric/PIN unlock -The optional biometric/PIN unlock feature uses your fingerprint, face, or PIN to encrypt your master password on-device. The encrypted password is stored in your browser's local storage (IndexedDB), and the decryption key is derived from your biometric via the WebAuthn PRF extension and it never leaves your device. When biometric/PIN unlock is used, the master password is decrypted from your device's secure storage and passed directly to the vault-opening function. Portpass explicitly clears the master password variable immediately after the vault opens and it is not retained in JavaScript memory beyond that single call. +The optional biometric/PIN unlock feature uses your fingerprint, face, or PIN to encrypt your master password on-device. The encrypted password is stored in your browser's local storage (IndexedDB), and the decryption key is derived from your biometric via the WebAuthn PRF extension and it never leaves your device. When biometric/PIN unlock is used, the master password is decrypted from your device's secure storage and passed directly to the vault-opening function. Portpass drops JavaScript references to the master password immediately after use, though JavaScript strings cannot be wiped in place by application code. An attacker with physical access to your device and browser profile could extract the ciphertext from IndexedDB, but cannot decrypt it without the biometric credential held in your device's secure hardware. @@ -109,7 +109,7 @@ If your master password changes, re-enroll biometric/PIN unlock. The old enrollm ## Secondary vault associations -When secondary vaults are linked to a primary vault, their master passwords are stored encrypted in your browser's IndexedDB. The encryption uses AES-256-GCM with a key derived inside the WASM engine from the primary vault's stretched key and this key never appears in JavaScript. JavaScript only ever handles ciphertext and nonces, which are not sensitive. +When secondary vaults are linked to a primary vault, their master passwords are stored encrypted in your browser's IndexedDB. The encryption uses AES-256-GCM with a key derived inside the WASM engine from the primary vault's stretched key and this key never appears in JavaScript. JavaScript receives a decrypted secondary master password only transiently when opening that secondary vault, then drops references after use; the reactive application state stores vault metadata, not secondary master passwords. **Changing the primary vault's master password** generates a new encryption key and any previously linked secondary vaults will no longer auto-unlock and must be re-linked. diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index f7d1e0f..1280b4b 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -60,6 +60,8 @@ func openDB(this js.Value, args []js.Value) interface{} { uuid := vaultUUID(newDB) if _, exists := databases[uuid]; !exists { databases[uuid] = newDB + } else { + newDB.Wipe() } result, _ := json.Marshal(map[string]string{"uuid": uuid}) @@ -73,6 +75,9 @@ func createDatabase(this js.Value, args []js.Value) interface{} { } newDB := pwsafe.NewV3("", args[0].String()) uuid := vaultUUID(newDB) + if old, exists := databases[uuid]; exists { + old.Wipe() + } databases[uuid] = newDB result, _ := json.Marshal(map[string]string{"uuid": uuid}) return string(result) @@ -83,7 +88,11 @@ func closeDB(this js.Value, args []js.Value) interface{} { if len(args) != 1 { return "invalid arguments: expected (vaultUuid)" } - delete(databases, args[0].String()) + uuid := args[0].String() + if db, ok := databases[uuid]; ok { + db.Wipe() + delete(databases, uuid) + } return nil } diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index ac7f7b5..a07c63f 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -10,7 +10,7 @@ copyFieldToClipboard, copyCustomFieldToClipboard, copyTOTP as wasmCopyTOTP, getTOTP, getFieldValue, getCustomFieldValue, } from '../wasm.js' - import { addSecondaryCredential, removeSecondaryCredential } from './secondaryVaults.js' + import { addSecondaryCredential, updateSecondaryHandle, removeSecondaryCredential } from './secondaryVaults.js' import { getSwitchboardUrl, getCrossProfileEnabled } from './delegates.js' import { isBiometricEnrolledForFile, unlockWithBiometric } from './biometric.js' import { getDelegates, verifyDelegateById, recordFill } from './delegates.js' @@ -753,7 +753,7 @@ )) secondaryHead[sv.uuid] = data.slice(72, 152) try { secondaryModified[sv.uuid] = (await handle.getFile()).lastModified } catch {} - try { await addSecondaryCredential(dbKey, filename, sv.uuid, sv.masterPassword, handle) } catch {} + try { await updateSecondaryHandle(dbKey, sv.uuid, filename, handle) } catch {} showToast('Saved to ' + (sv.name || filename), null, 2000) } else { const fname = sv.filename ?? 'vault' @@ -1241,6 +1241,11 @@ onclosed() } + function closeSecondarySetup() { + secondarySetup = null + sheetOpen = true + } + async function lockSecondaryVault(vaultUuid) { await removeSecondaryCredential(dbKey, vaultUuid) closeDatabase(vaultUuid) @@ -1303,13 +1308,16 @@ async function confirmSecondarySetup() { if (!secondarySetup?.password) return - secondarySetup = { ...secondarySetup, busy: true, error: '' } + const setup = secondarySetup + let secondaryPassword = setup.password + secondarySetup = { ...setup, password: '', busy: true, error: '' } // Biometric confirmation gesture if enrolled (proves identity without exposing master password) - if (secondarySetup.needsAuth) { + if (setup.needsAuth) { try { await unlockWithBiometric($selectedFile?.name ?? '') } catch (e) { + secondaryPassword = '' secondarySetup = { ...secondarySetup, busy: false, error: e.name === 'NotAllowedError' ? 'Authentication cancelled.' : 'Authentication failed: ' + e.message } return @@ -1319,17 +1327,17 @@ try { let secondaryUuid if (_secondaryHandle) { - secondaryUuid = await loadVaultFile(_secondaryHandle, secondarySetup.password) + secondaryUuid = await loadVaultFile(_secondaryHandle, secondaryPassword) } else { const buf = await _secondaryFallbackFile.arrayBuffer() - secondaryUuid = openDatabase(new Uint8Array(buf), secondarySetup.password) + secondaryUuid = openDatabase(new Uint8Array(buf), secondaryPassword) } if (secondaryUuid === dbKey) { // Don't closeDatabase here — same UUID means this IS the primary vault. // Closing it would remove the primary from WASM memory. secondarySetup = { ...secondarySetup, busy: false, - error: `"${secondarySetup.filename}" is already open as your primary vault and cannot also be added as a secondary vault.` } + error: `"${setup.filename}" is already open as your primary vault and cannot also be added as a secondary vault.` } return } @@ -1337,7 +1345,7 @@ // Don't closeDatabase here — this secondary is already in the WASM map. // Closing it would break the already-open secondary vault. secondarySetup = { ...secondarySetup, busy: false, - error: `"${secondarySetup.filename}" is already open as a secondary vault.` } + error: `"${setup.filename}" is already open as a secondary vault.` } return } @@ -1348,18 +1356,17 @@ try { const w = await _secondaryHandle.createWritable(); await w.abort(); readonly = false } catch {} } - await addSecondaryCredential(dbKey, secondarySetup.filename, secondaryUuid, secondarySetup.password, _secondaryHandle) + await addSecondaryCredential(dbKey, setup.filename, secondaryUuid, secondaryPassword, _secondaryHandle) secondaryVaults.update(vs => { const filtered = vs.filter(v => v.uuid !== secondaryUuid) return [...filtered, { handle: _secondaryHandle, - name: info?.name || secondarySetup.filename, - filename: secondarySetup.filename, + name: info?.name || setup.filename, + filename: setup.filename, readonly, items: items.map(i => ({ ...i, vaultUuid: secondaryUuid })), uuid: secondaryUuid, - masterPassword: secondarySetup.password, }] }) if (_secondaryHandle) { @@ -1370,6 +1377,8 @@ sheetOpen = true } catch (e) { secondarySetup = { ...secondarySetup, busy: false, error: 'Wrong password or invalid file.' } + } finally { + secondaryPassword = '' } } @@ -1546,8 +1555,8 @@ {#if secondarySetup} diff --git a/pwa/src/lib/StartPage.svelte b/pwa/src/lib/StartPage.svelte index 7ff73fb..67e0ba2 100644 --- a/pwa/src/lib/StartPage.svelte +++ b/pwa/src/lib/StartPage.svelte @@ -109,6 +109,7 @@ localStorage.setItem(offerKey, '1') mode = 'offer-biometric' } else { + password = '' onopened() } } @@ -159,6 +160,7 @@ error = 'Wrong password or invalid file.' console.error(e) } finally { + if (mode !== 'offer-biometric') password = '' busy = false } } @@ -167,6 +169,7 @@ // 1. Guard against re-entry if (busy) return; busy = true error = '' + let biometricPassword = null try { const fname = fileHandle?.name ?? fallbackFile?.name @@ -184,7 +187,6 @@ } // 3. Authenticate with Biometric - let biometricPassword try { biometricPassword = await unlockWithBiometric(fname) } catch (e) { @@ -214,13 +216,11 @@ vaultUuid = openDatabase(new Uint8Array(buf), biometricPassword) } catch (e) { console.error("[DEBUG] Decryption failed. Error:", e) - biometricPassword = null await clearBiometricForFile(fname) biometricEnrolled = false error = 'Biometric/PIN unlock is out of date — please enter your master password.' return } - biometricPassword = null // 6. UI/State updates dbItems.set(getDatabaseData(vaultUuid)) @@ -238,22 +238,29 @@ console.error(e) error = 'An unexpected error occurred.' } finally { + biometricPassword = null busy = false } } async function enableBiometric() { + if (!password) { + error = 'Enable biometric/PIN unlock later from vault settings.' + return + } busy = true; error = '' try { const info = getDatabaseInfo($selectedFile?.uuid ?? '') const fname = fallbackFile?.name ?? fileHandle?.name await enrollBiometric(password, info?.uuid, fname) biometricEnrolled = true + password = '' onopened() } catch (e) { error = e.message console.error(e) } finally { + password = '' busy = false } } @@ -265,10 +272,12 @@ const vaultUuid = createDatabase(password) dbItems.set(getDatabaseData(vaultUuid)) selectedFile.set({ handle: null, name: 'New vault', uuid: vaultUuid }) + password = '' onopened() } catch (e) { error = e.message } finally { + password = '' busy = false } } @@ -280,7 +289,12 @@ const opened = [] for (const cred of credentials) { const handle = cred.handle - if (!handle) continue // no stored handle; user must manually re-link + let secondaryPassword = cred.masterPassword + cred.masterPassword = '' + if (!handle) { + secondaryPassword = '' + continue // no stored handle; user must manually re-link + } try { if (handle.requestPermission) { let perm = await handle.requestPermission({ mode: 'readwrite' }) @@ -289,7 +303,7 @@ if (perm !== 'granted') continue } } - const vaultUuid = await loadVaultFile(handle, cred.masterPassword) + const vaultUuid = await loadVaultFile(handle, secondaryPassword) if (vaultUuid !== cred.vaultUuid) { closeDatabase(vaultUuid); continue } const info = getDatabaseInfo(vaultUuid) const items = getDatabaseData(vaultUuid) @@ -299,9 +313,12 @@ handle, name: info?.name || handle.name, filename: handle.name, readonly, items: items.map(i => ({ ...i, vaultUuid })), - uuid: vaultUuid, masterPassword: cred.masterPassword, + uuid: vaultUuid, }) - } catch {} + } catch { + } finally { + secondaryPassword = '' + } } secondaryVaults.set(opened) } catch {} @@ -430,11 +447,11 @@ {#if error}
{error}
{/if} - - + diff --git a/pwa/src/lib/VaultSheet.svelte b/pwa/src/lib/VaultSheet.svelte index 3751135..7e895b5 100644 --- a/pwa/src/lib/VaultSheet.svelte +++ b/pwa/src/lib/VaultSheet.svelte @@ -49,6 +49,13 @@ showSetupPw = false } + function closeSetup() { + setupMode = false + setupPassword = '' + setupError = '' + showSetupPw = false + } + function focusOnMount(node) { setTimeout(() => node.focus(), 0) } @@ -66,10 +73,9 @@ } await enrollBiometric(setupPassword, info?.uuid, filename) biometricEnrolled = true - setupMode = false - setupPassword = '' - showSetupPw = false + closeSetup() } catch (e) { + setupPassword = '' if (e.name === 'NotAllowedError') { setupError = 'Setup cancelled.' } else if (e.message?.includes('decrypt')) { @@ -924,8 +930,8 @@ {#if setupMode}