C2-INTEGRATION.md
C2 — Export Screen: Integration Note
Draft implementation of spec C2 (lines 289–337). Three Kotlin files plus this
note, all under app/drafts/. Package is already com.peachpasswords.ui.vault,
so they drop straight into the source tree. No existing file was edited
(PeachNav.kt, PeachAppNavHost.kt, VaultRepository.kt, VaultCrypto.kt,
VaultEntry.kt, ImportScreen.kt, RecoveryWordsScreen.kt, theme files were read
only).
Files to MOVE into the source set
| Draft file | Destination | Contents |
|---|---|---|
PeachScriptExport.kt |
ui/vault/PeachScriptExport.kt |
Pure Kotlin: buildPlaintext / buildEncrypted, hand-rolled JSON with escaping |
CsvExport.kt |
ui/vault/CsvExport.kt |
Pure Kotlin: toCsv + encodeField (RFC-4180 + formula-injection guard) |
ExportScreen.kt |
ui/vault/ExportScreen.kt |
Compose UI + ExportViewModel + scorePassword/strengthLabel |
C2-INTEGRATION.md stays in drafts (or delete after wiring) — it is not source.
Public composable signature
@Composable
fun ExportScreen(onBack: () -> Unit)
// (also has a defaulted vm = hiltViewModel() param, like ImportScreen / TrashScreen)
Nav wiring (one line)
PeachNav.Export route already exists. In PeachAppNavHost.kt, replace the
Export route's SimpleStub with:
ExportScreen(onBack = back)
where back is the same back lambda the other vault screens use (e.g. how
ImportScreen(onBack = back) is wired). No new route, no nav-graph change.
ExportScreen handles its own internal back: a sub-view (PeachScript / CSV)
returns to the choose view; the choose view calls onBack(). A single
onBack=back is correct.
Strength scorer thresholds chosen
scorePassword(pw): Int returns 0–4 (local, dependency-free, no network):
- +1 if length ≥ 8
- +1 if length ≥ 12
- +1 if ≥ 3 of {lowercase, uppercase, digit, symbol} present
- +1 if all 4 classes present
- empty → 0, clamped to 0..4
Labels: 0 very weak · 1 weak · 2 fair · 3 good · 4 strong.
The C2 gate is score ≥ 3 ("good"). [Generate backup] is disabled until
score ≥ 3 AND the two password fields match AND the vault is non-empty. When
a non-empty password scores < 3 the breach-colour error
"Backup password too weak (score < 3)" is shown (spec line 306). The 4-segment
bar fills score segments and tints to the accent once "good".
Round-trip confirmation (buildEncrypted ⇄ PeachScriptImport)
PeachScriptImport.decryptAndParse(buildEncrypted(es, pw), pw) returns
Success with the same six fields per entry. Reasoning, field by field against
the pinned format in B2-INTEGRATION.md and the importer source:
- Envelope shape.
buildEncryptedemits{"peachscript":1,"encrypted":true,"kdf":"argon2id","salt":"<b64>","blob":"<b64>"}. The importer readssaltandblobviajsonRawString(a"key":"..."regex) — key names and JSON-string shape match exactly. - Base64. Both sides use
java.util.Base64standard (non-URL-safe) codec:getEncoder()here,getDecoder()there. Salt is 16 bytes (VaultCrypto.generateSalt()=SALT_SIZE); blob isnonce(12) || ct||tag, soblob.size > NONCE_SIZE— passes the importer's length guards. - KDF.
saltfeedsVaultCrypto.deriveKey(pw, salt)on both sides (Argon2id, identical params from the sharedVaultCryptoconstants). - AEAD + AAD.
blobis exactlyVaultCrypto.encrypt(plaintextBytes, key, PeachScriptImport.PEACHSCRIPT_AAD). The importer callsVaultCrypto.decrypt(blob, key, PEACHSCRIPT_AAD)with the same constant AAD ("peachscript_export_v1").encryptprepends the 12-byte nonce;decryptstrips the first 12 bytes as the nonce. The GCM tag therefore verifies, so wrong-password / tamper paths surface asDecryptResult.WrongPasswordexactly as designed. - Inner plaintext. The decrypted bytes are
buildPlaintext(es)={"peachscript":1,"encrypted":false,"entries":[{...}]}. The importer'sparsePlaintextusesextractArrayBody("entries")+splitTopLevelObjects+jsonStringfortitle/username/password/url/ notes/totp— the exact keys this writer emits. - Escaping.
buildPlaintextescapes\,", and control chars (\n \r \t \b \f,\uXXXXfor the rest). The importer'sunescapeJsonreverses\n \t \r \" \\ \/ \uXXXX, so any value round-trips. (We never emit\/; forward slash is left literal, which is valid JSON and read back verbatim.)
The only normalisation is field projection (see assumptions): empty strings
become null on the way back because the importer's jsonString maps "" →
null, and parsePlaintext drops entries whose title is blank. So entries
with a non-blank title and otherwise-null optional fields round-trip to the same
ParsedEntry shape.
VaultRepository methods used (all already exist — no new API assumed)
fun getEntries(): List<VaultEntry>— returns active (non-deleted) entries (verified:entryList.filter { it.deletedAt == null }). Used for both the count/empty-vault gate and the export payload.
ExportViewModel injects VaultRepository + @ApplicationContext Context
(for SAF openOutputStream), exactly like ImportViewModel.
SAF / clipboard patterns
- PeachScript save:
CreateDocument("*/*"), suggested namepeach-backup.peach(mirrorsRecoveryWordsScreen'sCreateDocumentsave pattern)."*/*"is used because there is no registered MIME type for.peach. - CSV save:
CreateDocument("text/csv"), suggested namepeach-export.csv. - Copy to clipboard uses
ClipboardManager/ClipData.newPlainText("peach backup", json)— same approach as RecoveryWordsScreen's "copy all words".
Assumptions / things for the orchestrator to confirm
email→usernamefold. PeachScript and the CSVusernamecolumn have noemailfield, and the importer reads only six keys. Whenusernameis blank we emitemailin its place (so a webmail login's address is not lost). If the orchestrator prefers email dropped, change thee.username ?: e.emailfallbacks in bothPeachScriptExport.appendEntryandCsvExport.toCsv.- Only the six PeachScript fields are exported. Card/passkey/tags/history fields are intentionally NOT in PeachScript or CSV (neither format nor the B2 importer model them). A full-fidelity vault backup would need a richer format — out of scope for C2, which targets interop with the B2 importer.
- Empty-vault gate is enforced in the UI (
entryCount() == 0disables the export button and shows"Your vault is empty — nothing to export."). The spec says "Empty vault" error if 0 entries; exact copy can be tuned. - Strength gate copy uses the spec's literal string
"Backup password too weak (score < 3)"in breach colour. - CSV MIME is
text/csvfor the picker (stricter than Import's"*/*", since we control the extension on write). Swap to"*/*"if a device's SAF provider rejectstext/csv. - Entry-point wiring (Settings → Vault card → "Export" row) is out of scope for
this draft — only the
PeachNav.Exportroute swap is described.