C2-INTEGRATION.md

7.1 KB · raw

rendered · source

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):

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:

  1. Envelope shape. buildEncrypted emits {"peachscript":1,"encrypted":true,"kdf":"argon2id","salt":"<b64>","blob":"<b64>"}. The importer reads salt and blob via jsonRawString (a "key":"..." regex) — key names and JSON-string shape match exactly.
  2. Base64. Both sides use java.util.Base64 standard (non-URL-safe) codec: getEncoder() here, getDecoder() there. Salt is 16 bytes (VaultCrypto.generateSalt() = SALT_SIZE); blob is nonce(12) || ct||tag, so blob.size > NONCE_SIZE — passes the importer's length guards.
  3. KDF. salt feeds VaultCrypto.deriveKey(pw, salt) on both sides (Argon2id, identical params from the shared VaultCrypto constants).
  4. AEAD + AAD. blob is exactly VaultCrypto.encrypt(plaintextBytes, key, PeachScriptImport.PEACHSCRIPT_AAD). The importer calls VaultCrypto.decrypt(blob, key, PEACHSCRIPT_AAD) with the same constant AAD ("peachscript_export_v1"). encrypt prepends the 12-byte nonce; decrypt strips the first 12 bytes as the nonce. The GCM tag therefore verifies, so wrong-password / tamper paths surface as DecryptResult.WrongPassword exactly as designed.
  5. Inner plaintext. The decrypted bytes are buildPlaintext(es) = {"peachscript":1,"encrypted":false,"entries":[{...}]}. The importer's parsePlaintext uses extractArrayBody("entries") + splitTopLevelObjects + jsonString for title/username/password/url/ notes/totp — the exact keys this writer emits.
  6. Escaping. buildPlaintext escapes \, ", and control chars (\n \r \t \b \f, \uXXXX for the rest). The importer's unescapeJson reverses \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)

ExportViewModel injects VaultRepository + @ApplicationContext Context (for SAF openOutputStream), exactly like ImportViewModel.

SAF / clipboard patterns

Assumptions / things for the orchestrator to confirm

  1. email → username fold. PeachScript and the CSV username column have no email field, and the importer reads only six keys. When username is blank we emit email in its place (so a webmail login's address is not lost). If the orchestrator prefers email dropped, change the e.username ?: e.email fallbacks in both PeachScriptExport.appendEntry and CsvExport.toCsv.
  2. 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.
  3. Empty-vault gate is enforced in the UI (entryCount() == 0 disables 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.
  4. Strength gate copy uses the spec's literal string "Backup password too weak (score < 3)" in breach colour.
  5. CSV MIME is text/csv for the picker (stricter than Import's "*/*", since we control the extension on write). Swap to "*/*" if a device's SAF provider rejects text/csv.
  6. Entry-point wiring (Settings → Vault card → "Export" row) is out of scope for this draft — only the PeachNav.Export route swap is described.