B2-INTEGRATION.md
B2 — Import Screen: Integration Note
Draft implementation of spec B2 (lines 110–163). Three Kotlin files plus this note,
all under app/drafts/. They are written to drop straight into the source tree —
package is already com.peachpasswords.ui.vault.
Files to MOVE into the source set
Move these from app/drafts/ to
app/src/main/java/com/peachpasswords/ui/vault/ (package already correct —
package com.peachpasswords.ui.vault):
| Draft file | Destination | Contents |
|---|---|---|
CsvImport.kt |
ui/vault/CsvImport.kt |
Pure Kotlin: ParsedEntry, ImportParseResult, CsvImport (provider detection + CSV/JSON parsers, RFC-4180 splitter) |
PeachScriptImport.kt |
ui/vault/PeachScriptImport.kt |
Pure Kotlin: PeachScript sniff/detect/decrypt/parse |
ImportScreen.kt |
ui/vault/ImportScreen.kt |
Compose UI + ImportViewModel |
B2-INTEGRATION.md stays in drafts (or delete after wiring) — it is not source.
No edits were made to any existing file. PeachNav.kt, PeachAppNavHost.kt,
VaultRepository.kt, VaultEntry.kt, VaultCrypto.kt were read only.
Public composable signature
@Composable
fun ImportScreen(onBack: () -> Unit)
// (also has a defaulted vm = hiltViewModel() param, like TrashScreen)
Nav wiring
PeachNav.Import route already exists. In PeachAppNavHost.kt, replace the
Import route's SimpleStub with:
ImportScreen(onBack = back)
where back is the same back lambda the other vault screens use (e.g. how
TrashScreen(onBack = back) is wired). No new route, no nav-graph change needed.
Note: ImportScreen also handles internal back navigation itself (preview/error →
input view, sub-view → choose view); it only calls onBack() when at the top
choose view or after Done. So a single onBack=back is correct.
VaultRepository methods used (all already exist — no new API assumed)
suspend fun addEntry(draft: VaultEntry): VaultEntry— import loops this per parsed entry (confirmed: no batch method exists).fun getEntries(): List<VaultEntry>— used by REPLACE mode to enumerate current active entries.suspend fun deleteEntry(id: String)— REPLACE soft-deletes existing active entries before adding the imported set. (Chose soft-delete over a hard wipe so a mistaken "Replace" is recoverable from Trash. If the orchestrator wants a true replace, swap topurgeEntryordestroyVault— but that is destructive.)
VaultEntry drafts are built with id = "" (repo assigns UUID), type = "login",
and createdAt/modifiedAt = now (repo overrides modifiedAt with a monotonic stamp).
VaultCrypto API found (quoted from source)
From app/src/main/java/com/peachpasswords/vault/VaultCrypto.kt:
object VaultCrypto {
const val ARGON2_MEMORY_KB = 65536
const val ARGON2_ITERATIONS = 3
const val ARGON2_PARALLELISM = 4
const val ARGON2_HASH_LENGTH = 32
const val SALT_SIZE = 16
const val NONCE_SIZE = 12
const val TAG_LENGTH_BITS = 128
const val KEY_SIZE = 32
fun deriveKey(password: String, salt: ByteArray): ByteArray // Argon2id (BouncyCastle)
fun encrypt(plaintext: ByteArray, key: ByteArray, aad: String): ByteArray // nonce(12) || ct||tag
fun decrypt(data: ByteArray, key: ByteArray, aad: String): ByteArray // expects nonce(12) prefix
fun generateSalt(): ByteArray // 16 bytes
fun zero(bytes: ByteArray)
}
- KDF: Argon2id, 64 MiB memory, 3 iterations, parallelism 4, 32-byte output.
- AEAD: AES-256-GCM, 128-bit tag, 12-byte random nonce prepended to the
ciphertext (
encryptreturnsnonce + cipher.doFinal(...);decryptreads the first 12 bytes as the nonce). AAD is a UTF-8 string argument.
PeachScriptImport.decryptAndParse is implemented end-to-end against this API
(derive → decrypt → parse), since the on-the-wire shape is fully specified by the
header format below. A GCM tag mismatch surfaces as WrongPassword.
PeachScript header / export format chosen (C2 export MUST mirror this)
A PeachScript export is a single JSON object carrying a magic marker key
"peachscript" (also the format version).
Plaintext:
{
"peachscript": 1,
"encrypted": false,
"entries": [
{ "title": "...", "username": "...", "password": "...",
"url": "...", "notes": "...", "totp": "..." }
]
}
Encrypted (backup-password protected):
{
"peachscript": 1,
"encrypted": true,
"kdf": "argon2id",
"salt": "<base64, 16 bytes = VaultCrypto.SALT_SIZE>",
"blob": "<base64: 12-byte nonce || AES-256-GCM ciphertext||tag>"
}
blobis exactlyVaultCrypto.encrypt(plaintextJsonBytes, key, AAD)output (nonce prepended).saltfeedsVaultCrypto.deriveKey(backupPassword, salt).- Decrypted plaintext is the UTF-8 bytes of the plaintext-shape JSON object
above (with
"encrypted": false). - AAD is the constant string
"peachscript_export_v1"(PeachScriptImport.PEACHSCRIPT_AAD). C2 export must encrypt with this exact AAD or the GCM tag check fails and the user sees "Incorrect backup password." - Detection:
looksLikePeachScript= trimmed text starts with{and contains"peachscript".isEncrypted= contains"encrypted": true. Standard base64 (not URL-safe) is used forsalt/blob.
CSV / provider detection summary
CsvImport.detectProvider(content) returns one of:
"Bitwarden CSV", "Bitwarden JSON", "1Password CSV", "Chrome CSV",
"Edge CSV", "Safari CSV", "CSV" (generic), or null (unrecognised).
- Chrome and Edge share the identical Chromium header
(
name,url,username,password) and are indistinguishable from content alone — detection returns"Chrome CSV"; UI copy says "Chrome/Edge".parsetreats both identically. - Bitwarden JSON detection is a shape check (
{+"items": [); only login items (typeabsent or== 1) are imported. - The CSV parser is a single RFC-4180-ish state machine handling quoted fields,
embedded commas, doubled quotes (
""→"), and CR/LF inside quotes. detectAndParsereturns a sealedImportParseResultmapping to the spec error strings (UNRECOGNISED / CORRUPTED / NO_ENTRIES).
Spec error strings (in ImportScreen.ImportErrors)
- Unrecognised PeachScript: "This doesn't look like a PeachScript export."
- Wrong backup password: "Incorrect backup password."
- Corrupted: "This export appears to be corrupted."
- No entries: "No entries found in this export."
- (extras) "Couldn't read that file." / "Unrecognised file format."
Assumptions / things for the orchestrator to confirm
- REPLACE uses soft-delete (recoverable via Trash). Change to a hard wipe if
the spec's
RESTORE_ENTRIESsemantics require it. GetContent()launched with"*/*"(not.csv,.json) because some exporters report odd MIME types for CSV; the parser sniffs content regardless. Swap toOpenDocument()witharrayOf("text/csv","application/json")if a stricter picker is preferred.- The minimal hand-rolled JSON scanners in
CsvImport/PeachScriptImportavoid adding a JSON dependency to the pure modules. Ifkotlinx.serializationis already on the classpath (it is —VaultEntryis@Serializable), these could be swapped for a typed decode later; kept dependency-free here for testability. - Entry-point wiring (Lock screen RestoreTabs pills, Settings → Vault → Import
row) is out of scope for this draft — only the
PeachNav.Importroute swap is described. Those entry points just need to navigate to the existing route.