424682531f2f
feat: Carry PeachScript metadata sections (v2.3.31)
- time
- 2026-06-26 09:13 UTC · 4 months ago
- machine
- koh2
- parent
- cb5e4242bca1
↓ peach-android-42468253.tar.gzatlas at this save
files changed7 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/vault/ExportScreen.kt+9 −1app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+17 −1app/src/main/java/com/peachpasswords/ui/vault/PeachScriptExport.kt+17 −2app/src/main/java/com/peachpasswords/ui/vault/PeachScriptImport.kt+14 −2app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+65 −9app/src/test/java/com/peachpasswords/ui/vault/ExportRoundTripTest.kt+22 −0app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 23300020- versionName "2.3.30"19+ versionCode 23310020+ versionName "2.3.31"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/ui/vault/ExportScreen.kt+9 −1
@@ -46,6 +46,7 @@4646 import androidx.compose.ui.unit.sp4747 import androidx.hilt.navigation.compose.hiltViewModel4848 import androidx.lifecycle.ViewModel49+import com.peachpasswords.license.LicenseRepository4950 import com.peachpasswords.ui.components.SecureFlagEffect5051 import com.peachpasswords.ui.theme.LocalPeachAccent5152 import com.peachpasswords.ui.theme.peachColors@@ -86,6 +87,7 @@8687 @HiltViewModel8788 class ExportViewModel @Inject constructor(8889 private val vault: VaultRepository,90+ private val license: LicenseRepository,8991 @ApplicationContext private val appContext: Context,9092 ) : ViewModel() {9193 @@ -96,7 +98,13 @@9698 9799 /** Encrypted PeachScript export (backup-password protected). */98100 fun buildPeachScript(backupPassword: String): String =99- PeachScriptExport.buildEncrypted(entries(), backupPassword)101+ PeachScriptExport.buildEncrypted(102+ entries = entries(),103+ backupPassword = backupPassword,104+ settings = vault.settings.value,105+ licenseKey = license.getLicenseKey()?.takeIf { license.validateLicense(it) },106+ licenseActivatedAt = license.getLicenseIssuedAtMillis(),107+ )100108 101109 /** Unencrypted CSV export. */102110 fun buildCsv(): String = CsvExport.toCsv(entries())app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+17 −1
@@ -62,6 +62,7 @@6262 import kotlinx.coroutines.launch6363 import kotlinx.coroutines.withContext6464 import javax.inject.Inject65+import com.peachpasswords.license.LicenseRepository6566 6667 // ─────────────────────────────────────────────────────────────────────────────6768 // ViewModel@@ -80,6 +81,8 @@8081 val sourceLabel: String,8182 val entries: List<ParsedEntry>,8283 val fullEntries: List<VaultEntry>? = null,84+ val settings: com.peachpasswords.vault.VaultSettings? = null,85+ val licenseKey: String? = null,8386 )8487 8588 sealed interface ImportUiState {@@ -108,6 +111,7 @@108111 @HiltViewModel109112 class ImportViewModel @Inject constructor(110113 private val vault: VaultRepository,114+ private val license: LicenseRepository,111115 @ApplicationContext private val appContext: Context,112116 ) : ViewModel() {113117 @@ -163,7 +167,15 @@163167 totp = e.totp,164168 )165169 }166- ImportUiState.Preview(ImportPreview("PeachScript", parsed, result.entries))170+ ImportUiState.Preview(171+ ImportPreview(172+ sourceLabel = "PeachScript",173+ entries = parsed,174+ fullEntries = result.entries,175+ settings = result.settings,176+ licenseKey = result.licenseKey,177+ ),178+ )167179 }168180 PeachScriptImport.DecryptResult.WrongPassword ->169181 ImportUiState.Failed(ImportErrors.WRONG_PASSWORD)@@ -261,6 +273,10 @@261273 runCatching { vault.addEntry(draft) }.onSuccess { imported++ }262274 }263275 }276+ preview.settings?.let { vault.updateSettings(it) }277+ preview.licenseKey278+ ?.takeIf { license.validateLicense(it) }279+ ?.let { license.storeLicense(it) }264280 _state.value = ImportUiState.Done(imported)265281 } catch (e: Exception) {266282 _state.value = ImportUiState.Failed(ImportErrors.CORRUPTED)app/src/main/java/com/peachpasswords/ui/vault/PeachScriptExport.kt+17 −2
@@ -3,6 +3,7 @@33 import com.peachpasswords.vault.PeachPayloadCodec44 import com.peachpasswords.vault.PeachScript55 import com.peachpasswords.vault.VaultEntry6+import com.peachpasswords.vault.VaultSettings67 78 /**89 * PeachScript export (MF-2) — writes the browser's PCHS binary payload@@ -19,6 +20,20 @@1920 * Encrypted PeachScript export of [entries], protected by [backupPassword].2021 * PeachScript is always encrypted (the browser format has no plaintext mode).2122 */22- fun buildEncrypted(entries: List<VaultEntry>, backupPassword: String): String =23- PeachScript.encode(PeachPayloadCodec.buildPayload(entries, backupPassword))23+ fun buildEncrypted(24+ entries: List<VaultEntry>,25+ backupPassword: String,26+ settings: VaultSettings = VaultSettings(),27+ licenseKey: String? = null,28+ licenseActivatedAt: Long? = null,29+ ): String =30+ PeachScript.encode(31+ PeachPayloadCodec.buildPayload(32+ entries = entries,33+ passphrase = backupPassword,34+ settings = settings,35+ licenseKey = licenseKey,36+ licenseActivatedAt = licenseActivatedAt,37+ ),38+ )2439 }app/src/main/java/com/peachpasswords/ui/vault/PeachScriptImport.kt+14 −2
@@ -3,6 +3,7 @@33 import com.peachpasswords.vault.PeachPayloadCodec44 import com.peachpasswords.vault.PeachScript55 import com.peachpasswords.vault.VaultEntry6+import com.peachpasswords.vault.VaultSettings67 78 /**89 * PeachScript import (MF-2) — parses the browser's PCHS binary payload back into@@ -16,7 +17,12 @@1617 1718 /** Outcome of decrypting + parsing an encrypted export. */1819 sealed interface DecryptResult {19- data class Success(val entries: List<VaultEntry>) : DecryptResult20+ data class Success(21+ val entries: List<VaultEntry>,22+ val settings: VaultSettings = VaultSettings(),23+ val licenseKey: String? = null,24+ val licenseActivatedAt: Long? = null,25+ ) : DecryptResult2026 data object WrongPassword : DecryptResult2127 data object Corrupted : DecryptResult2228 }@@ -42,7 +48,13 @@4248 return DecryptResult.Corrupted4349 }4450 return try {45- DecryptResult.Success(PeachPayloadCodec.parsePayload(bytes, backupPassword))51+ val payload = PeachPayloadCodec.parsePayloadData(bytes, backupPassword)52+ DecryptResult.Success(53+ entries = payload.entries,54+ settings = payload.settings,55+ licenseKey = payload.licenseKey,56+ licenseActivatedAt = payload.licenseActivatedAt,57+ )4658 } catch (e: PeachPayloadCodec.PeachScriptException) {4759 if (e.message == "INVALID_PASSPHRASE") DecryptResult.WrongPassword else DecryptResult.Corrupted4860 } catch (_: Exception) {app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+65 −9
@@ -87,15 +87,31 @@8787 8888 class PeachScriptException(message: String) : Exception(message)8989 90+ data class ParsedPayload(91+ val entries: List<VaultEntry>,92+ val settings: VaultSettings = VaultSettings(),93+ val licenseKey: String? = null,94+ val licenseActivatedAt: Long? = null,95+ )96+9097 // ---- public API -------------------------------------------------------9198 9299 /** Build the encrypted envelope `salt||nonce||ct||tag` from [entries]. */93- fun buildPayload(entries: List<VaultEntry>, passphrase: String): ByteArray {94- // sectionCount = 1 (ENTRIES). The browser also emits SETTINGS etc.; Android95- // export carries entries only — a valid payload the browser reads (it skips96- // absent sections). See BROWSER-SPEC §B2.11.100+ fun buildPayload(101+ entries: List<VaultEntry>,102+ passphrase: String,103+ settings: VaultSettings = VaultSettings(),104+ licenseKey: String? = null,105+ licenseActivatedAt: Long? = null,106+ ): ByteArray {97107 val entriesRaw = encodeEntriesSection(entries)98- val sections = listOf(buildSection(SECTION_ENTRIES, entriesRaw))108+ val sections = buildList {109+ add(buildSection(SECTION_ENTRIES, entriesRaw))110+ add(buildSection(SECTION_SETTINGS, encodeSettingsSection(settings)))111+ if (!licenseKey.isNullOrBlank()) {112+ add(buildSection(SECTION_LICENSE, encodeLicenseSection(licenseKey, licenseActivatedAt)))113+ }114+ }99115 100116 val header = ByteArray(HEADER_LEN)101117 writeU32BE(header, 0, MAGIC.toLong())@@ -124,7 +140,11 @@124140 }125141 126142 /** Parse the encrypted envelope back to entries. Throws [PeachScriptException]. */127- fun parsePayload(envelope: ByteArray, passphrase: String): List<VaultEntry> {143+ fun parsePayload(envelope: ByteArray, passphrase: String): List<VaultEntry> =144+ parsePayloadData(envelope, passphrase).entries145+146+ /** Parse the encrypted envelope back to all supported payload metadata. */147+ fun parsePayloadData(envelope: ByteArray, passphrase: String): ParsedPayload {128148 if (envelope.size < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {129149 throw PeachScriptException("PAYLOAD_TOO_SHORT")130150 }@@ -162,6 +182,9 @@162182 val sectionCount = body[5].toInt() and 0xff163183 164184 var entries = emptyList<VaultEntry>()185+ var settings = VaultSettings()186+ var licenseKey: String? = null187+ var licenseActivatedAt: Long? = null165188 var cursor = HEADER_LEN166189 for (i in 0 until sectionCount) {167190 if (cursor + 9 > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")@@ -176,13 +199,24 @@176199 if (data.size != length) throw PeachScriptException("SECTION_LENGTH_MISMATCH")177200 when (type) {178201 SECTION_ENTRIES -> entries = decodeEntriesSection(data)179- // SETTINGS/LICENSE/RECALL/DECLINED_CARDS and unknown types are180- // skipped — Android needs only the entries (BROWSER-SPEC §B2.12).202+ SECTION_SETTINGS -> settings = decodeSettingsSection(data) ?: VaultSettings()203+ SECTION_LICENSE -> {204+ val license = decodeLicenseSection(data)205+ licenseKey = license?.first206+ licenseActivatedAt = license?.second207+ }208+ // RECALL/DECLINED_CARDS and unknown types are skipped. Android209+ // does not expose browser manual-summon or declined-card stores.181210 else -> {}182211 }183212 cursor = dataEnd184213 }185- return entries214+ return ParsedPayload(215+ entries = entries,216+ settings = settings,217+ licenseKey = licenseKey,218+ licenseActivatedAt = licenseActivatedAt,219+ )186220 }187221 188222 // ---- section assembly (peachPayload.ts:316-333) -----------------------@@ -195,6 +229,28 @@195229 writeU32LE(out, 5, compressed.size.toLong())196230 System.arraycopy(compressed, 0, out, 9, compressed.size)197231 return out232+ }233+234+ private fun encodeSettingsSection(settings: VaultSettings): ByteArray =235+ VaultCodec.encodeSettings(settings).toByteArray(Charsets.UTF_8)236+237+ private fun decodeSettingsSection(data: ByteArray): VaultSettings? =238+ VaultCodec.decodeSettings(String(data, Charsets.UTF_8))239+240+ private fun encodeLicenseSection(key: String, activatedAt: Long?): ByteArray {241+ val obj = buildJsonObject {242+ put("key", JsonPrimitive(key))243+ if (activatedAt != null) put("activatedAt", JsonPrimitive(activatedAt))244+ }245+ return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8)246+ }247+248+ private fun decodeLicenseSection(data: ByteArray): Pair<String, Long?>? {249+ val obj = runCatching { json.parseToJsonElement(String(data, Charsets.UTF_8)).jsonObject }.getOrNull()250+ ?: return null251+ val key = obj["key"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null252+ val activatedAt = obj["activatedAt"]?.jsonPrimitive?.longOrNull253+ return key to activatedAt198254 }199255 200256 // ---- entries section: string table + packed bodies --------------------app/src/test/java/com/peachpasswords/ui/vault/ExportRoundTripTest.kt+22 −0
@@ -1,6 +1,7 @@11 package com.peachpasswords.ui.vault22 33 import com.peachpasswords.vault.VaultEntry4+import com.peachpasswords.vault.VaultSettings45 import org.junit.Assert.assertEquals56 import org.junit.Assert.assertTrue67 import org.junit.Test@@ -58,6 +59,27 @@5859 val blob = PeachScriptExport.buildEncrypted(listOf(entry()), "backup-pass-12")5960 val r = PeachScriptImport.decryptAndParse(blob, "wrong-password")6061 assertTrue(r is PeachScriptImport.DecryptResult.WrongPassword)62+ }63+64+ @Test fun peachscript_round_trips_settings_and_license_metadata() {65+ val settings = VaultSettings(theme = "light", accentColor = "sage", reduceMotion = true)66+ val blob = PeachScriptExport.buildEncrypted(67+ entries = listOf(entry()),68+ backupPassword = "backup-pass-12",69+ settings = settings,70+ licenseKey = "PPK-test-license",71+ licenseActivatedAt = 1_782_000_000_000L,72+ )73+74+ val ok = PeachScriptImport.decryptAndParse(blob, "backup-pass-12")75+76+ assertTrue(ok is PeachScriptImport.DecryptResult.Success)77+ ok as PeachScriptImport.DecryptResult.Success78+ assertEquals("light", ok.settings.theme)79+ assertEquals("sage", ok.settings.accentColor)80+ assertTrue(ok.settings.reduceMotion)81+ assertEquals("PPK-test-license", ok.licenseKey)82+ assertEquals(1_782_000_000_000L, ok.licenseActivatedAt)6183 }6284 6385 @Test fun export_file_names_match_browser_date_format() {