30cd436080de

M-03: preserve last4 + entry IDs on REPLACE; tag/url overflow throws instead of truncating

time
2026-07-29 14:27 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
24fe1013d9b3
3 files app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+6 −2app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+16 −10app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+15 −3
app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+6 −2
@@ -312,8 +312,12 @@312312                 val fulls = preview.fullEntries313313                 val drafts = if (fulls != null) {314314                     // PeachScript: the complete entries (all fields, all types).315-                    // Re-id so an import can never overwrite an existing entry.316-                    fulls.map { it.copy(id = "") }315+                    // M-03: REPLACE is a restore of *these* entries, so keep their316+                    // ids — re-iding turned a restore into delete-plus-new317+                    // identities that paired devices then re-synced as churn.318+                    // MERGE folds into an existing set, so re-id there: it is the319+                    // explicit collision resolution (never overwrite a live entry).320+                    if (mode == ImportMode.REPLACE) fulls else fulls.map { it.copy(id = "") }317321                 } else {318322                     preview.entries.map { p ->319323                         VaultEntry(

view file

app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+16 −10
@@ -418,9 +418,10 @@418418                 w.lpString(entry.cvc ?: "")419419                 w.u16(table.intern(entry.cardNickname))420420                 w.u8(networkToByte(entry.cardNetwork))421-                // Android has no last4 field — derive it from the card number so the422-                // wire format matches the browser (BROWSER-SPEC: card last4 note).423-                w.u16(table.intern(deriveLast4(entry)))421+                // M-03: prefer the stored field. Deriving it lost the last four422+                // for cards held without a full PAN (the browser stores last4423+                // independently, so those cards exported as blank).424+                w.u16(table.intern(entry.last4?.takeIf { it.isNotEmpty() } ?: deriveLast4(entry)))424425                 if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)425426                 if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))426427             }@@ -491,7 +492,7 @@491492                 val cvc = r.lpString()492493                 val nicknameIdx = r.u16()493494                 val network = networkFromByte(r.u8())494-                r.u16() // last4 index — Android has no field; dropped (number carries it)495+                val last4 = lookup(strings, r.u16()) // M-03: was read and dropped495496                 val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null496497                 val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null497498                 VaultEntry(@@ -500,6 +501,7 @@500501                     cardNumber = cardNumber.ifEmpty { null }, expMonth = expMonth.ifEmpty { null },501502                     expYear = expYear.ifEmpty { null }, cvc = cvc.ifEmpty { null },502503                     cardNickname = lookup(strings, nicknameIdx), cardNetwork = network,504+                    last4 = last4,503505                     tags = tags, notes = notes,504506                     createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,505507                 )@@ -515,16 +517,20 @@515517         }516518     }517519 520+    // M-03: the count is a u8, so >255 items cannot be represented. Truncating521+    // silently dropped user data into an export that still looked successful —522+    // fail loudly instead and let the caller decide (matches lpString's523+    // STRING_TOO_LONG_U8 behaviour for the same reason).518524     private fun writeTags(w: BinaryWriter, table: StringTable, tags: List<String>) {519-        val n = minOf(tags.size, 0xff)520-        w.u8(n)521-        for (i in 0 until n) w.u16(table.intern(tags[i]))525+        if (tags.size > 0xff) throw PeachScriptException("TAG_LIST_TOO_LONG")526+        w.u8(tags.size)527+        for (t in tags) w.u16(table.intern(t))522528     }523529 524530     private fun writeStrings(w: BinaryWriter, table: StringTable, items: List<String>) {525-        val n = minOf(items.size, 0xff)526-        w.u8(n)527-        for (i in 0 until n) w.u16(table.intern(items[i]))531+        if (items.size > 0xff) throw PeachScriptException("URL_LIST_TOO_LONG")532+        w.u8(items.size)533+        for (s in items) w.u16(table.intern(s))528534     }529535 530536     private fun readTags(r: BinaryReader, strings: List<String?>): List<String> {

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+15 −3
@@ -1058,14 +1058,21 @@10581058                 ),10591059             )10601060         }1061-        // H-03: capture rollback and swap the list under one stateLock.1061+        // M-03: an incoming entry that shares an id with an existing live entry1062+        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1063+        // the old one and then appending the new one would leave the list with1064+        // two entries of the same id — the tombstoned one would be returned by1065+        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1066+        // superseded entries from the tombstone set instead.1067+        val incomingIds = ready.map { it.id }.toHashSet()10621068         val rollback = mutateState {10631069             val r = entryList.toMutableList()10641070             val replaced = entryList.map { entry ->10651071                 if (entry.deletedAt != null) return@map entry1072+                if (entry.id in incomingIds) return@map null10661073                 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)10671074                 entry.copy(deletedAt = stamp, modifiedAt = stamp)1068-            }1075+            }.filterNotNull()10691076             entryList = (replaced + ready).toMutableList()10701077             r10711078         }@@ -1777,11 +1784,16 @@17771784      * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),17781785      * so it is stored explicitly and survives the sync wire format rather than being17791786      * re-derived at render time. Non-card entries pass through unchanged.1787+     *1788+     * M-03: never overwrite a stored last4 with null. A card kept only as last41789+     * (e.g. after a PeachScript import that had no PAN) must keep that value;1790+     * re-deriving from an absent cardNumber would silently zero the field.17801791      */17811792     private fun withDerivedLast4(entry: VaultEntry): VaultEntry {17821793         if (!entry.isCard()) return entry17831794         val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1784-        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1795+        val derived = if (digits.length >= 4) digits.takeLast(4) else null1796+        return entry.copy(last4 = derived ?: entry.last4)17851797     }17861798 17871799     /**

view file