package lib import ( "archive/zip" "bytes" "encoding/csv" "encoding/json" "io" "strconv" "strings" "time" ) // DetectAndParse attempts to parse known password manager formats directly. // Returns (entries, true) if format recognized, (nil, false) if unknown. func DetectAndParse(content []byte) ([]VaultData, bool) { // If it's a zip, extract the first JSON file and parse that if content, ok := extractFromZip(content); ok { return DetectAndParse(content) } // Strip UTF-8 BOM if present (common in browser CSV exports) content = bytes.TrimPrefix(content, []byte{0xEF, 0xBB, 0xBF}) // Try Bitwarden/generic JSON array with "items" key if entries, ok := parseBitwardenJSON(content); ok { AutoL2Fields(entries) return entries, true } // Try Proton Pass JSON if entries, ok := parseProtonJSON(content); ok { AutoL2Fields(entries) return entries, true } // Try Chrome/Firefox CSV if entries, ok := parseBrowserCSV(content); ok { AutoL2Fields(entries) return entries, true } return nil, false } // extractFromZip tries to read content as a zip archive and returns the first // JSON or CSV file found inside. Used for Proton Pass exports (zip containing JSON). func extractFromZip(content []byte) ([]byte, bool) { r, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) if err != nil { return nil, false } for _, f := range r.File { name := strings.ToLower(f.Name) if strings.HasSuffix(name, ".json") || strings.HasSuffix(name, ".csv") { rc, err := f.Open() if err != nil { continue } data, err := io.ReadAll(rc) rc.Close() if err != nil { continue } return data, true } } return nil, false } // readCSV parses content with the given delimiter. Returns (records, true) if valid with ≥2 rows. func readCSV(content []byte, delim rune) ([][]string, bool) { r := csv.NewReader(bytes.NewReader(content)) r.Comma = delim r.LazyQuotes = true r.TrimLeadingSpace = true records, err := r.ReadAll() if err != nil || len(records) < 2 { return nil, false } // Sanity: header should have at least 3 columns if len(records[0]) < 3 { return nil, false } return records, true } // --- Chrome CSV --- // Columns: name,url,username,password (Chrome) // Columns: url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged (Firefox) func parseBrowserCSV(content []byte) ([]VaultData, bool) { // Try comma first, then semicolon (European locale exports) records, ok := readCSV(content, ',') if !ok { records, ok = readCSV(content, ';') } if !ok { return nil, false } header := records[0] colIdx := map[string]int{} for i, h := range header { colIdx[strings.ToLower(strings.TrimSpace(h))] = i } // Detect Chrome: name, url, username, password // Detect Firefox: url, username, password (+ extras) urlCol, hasURL := colIdx["url"] userCol, hasUser := colIdx["username"] passCol, hasPass := colIdx["password"] nameCol, hasName := colIdx["name"] // Firefox: timePasswordChanged (Unix microseconds) timeChangedCol, hasTimeChanged := colIdx["timepasswordchanged"] if !hasURL || !hasUser || !hasPass { return nil, false } var entries []VaultData for _, row := range records[1:] { if len(row) <= urlCol || len(row) <= passCol { continue } title := "" if hasName && len(row) > nameCol { title = row[nameCol] } if title == "" { title = row[urlCol] } entry := VaultData{ Title: title, Type: "credential", Fields: []VaultField{ {Label: "Username", Value: row[userCol], Kind: "text"}, {Label: "Password", Value: row[passCol], Kind: "password"}, }, } if row[urlCol] != "" { entry.URLs = []string{row[urlCol]} } // Firefox stores timestamps as Unix microseconds if hasTimeChanged && len(row) > timeChangedCol && row[timeChangedCol] != "" { if us, err := strconv.ParseInt(row[timeChangedCol], 10, 64); err == nil && us > 0 { entry.SourceModified = us / 1_000_000 // microseconds → seconds } } entries = append(entries, entry) } return entries, len(entries) > 0 } // --- Bitwarden JSON --- type bitwardenExport struct { Items []bitwardenItem `json:"items"` } type bitwardenItem struct { Name string `json:"name"` Type int `json:"type"` // 1=login, 2=note, 3=card, 4=identity Notes string `json:"notes"` RevisionDate string `json:"revisionDate"` // RFC3339 Login *bitwardenLogin `json:"login"` Card *bitwardenCard `json:"card"` Identity *bitwardenIdentity `json:"identity"` } type bitwardenLogin struct { Username string `json:"username"` Password string `json:"password"` Totp string `json:"totp"` URIs []struct{ URI string `json:"uri"` } `json:"uris"` } type bitwardenCard struct { CardholderName string `json:"cardholderName"` Number string `json:"number"` ExpMonth string `json:"expMonth"` ExpYear string `json:"expYear"` Code string `json:"code"` } type bitwardenIdentity struct { FirstName string `json:"firstName"` LastName string `json:"lastName"` Email string `json:"email"` Phone string `json:"phone"` Address1 string `json:"address1"` City string `json:"city"` State string `json:"state"` PostalCode string `json:"postalCode"` Country string `json:"country"` } func parseBitwardenJSON(content []byte) ([]VaultData, bool) { var bw bitwardenExport if err := json.Unmarshal(content, &bw); err != nil || len(bw.Items) == 0 { return nil, false } var entries []VaultData for _, item := range bw.Items { vd := VaultData{Title: item.Name, Notes: item.Notes} switch item.Type { case 1: // login vd.Type = "credential" if item.Login != nil { vd.Fields = append(vd.Fields, VaultField{Label: "Username", Value: item.Login.Username, Kind: "text"}) vd.Fields = append(vd.Fields, VaultField{Label: "Password", Value: item.Login.Password, Kind: "password"}) if item.Login.Totp != "" { vd.Fields = append(vd.Fields, VaultField{Label: "TOTP Seed", Value: item.Login.Totp, Kind: "totp"}) } for _, u := range item.Login.URIs { if u.URI != "" { vd.URLs = append(vd.URLs, u.URI) } } } case 2: // note vd.Type = "note" vd.Fields = append(vd.Fields, VaultField{Label: "Content", Value: item.Notes, Kind: "text"}) vd.Notes = "" case 3: // card vd.Type = "card" if item.Card != nil { vd.Fields = append(vd.Fields, VaultField{Label: "Cardholder", Value: item.Card.CardholderName, Kind: "text"}) vd.Fields = append(vd.Fields, VaultField{Label: "Number", Value: item.Card.Number, Kind: "text", L2: true}) vd.Fields = append(vd.Fields, VaultField{Label: "CVV", Value: item.Card.Code, Kind: "text", L2: true}) vd.Fields = append(vd.Fields, VaultField{Label: "Expiry", Value: item.Card.ExpMonth + "/" + item.Card.ExpYear, Kind: "text"}) } case 4: // identity vd.Type = "identity" if item.Identity != nil { id := item.Identity addField := func(label, value string) { if value != "" { vd.Fields = append(vd.Fields, VaultField{Label: label, Value: value, Kind: "text"}) } } addField("First Name", id.FirstName) addField("Last Name", id.LastName) addField("Email", id.Email) addField("Phone", id.Phone) addField("Address", id.Address1) addField("City", id.City) addField("State", id.State) addField("ZIP", id.PostalCode) addField("Country", id.Country) } } // Parse revisionDate → SourceModified if item.RevisionDate != "" { if t, err := time.Parse(time.RFC3339, item.RevisionDate); err == nil { vd.SourceModified = t.Unix() } } entries = append(entries, vd) } return entries, len(entries) > 0 } // --- Proton Pass JSON --- // Proton nests everything under item.data: type, metadata, content are all inside data. type protonExport struct { Vaults map[string]protonVault `json:"vaults"` } type protonVault struct { Items []protonItem `json:"items"` } type protonItem struct { Data protonItemData `json:"data"` ModifyTime int64 `json:"modifyTime"` // Unix seconds CreateTime int64 `json:"createTime"` } type protonItemData struct { Type string `json:"type"` Metadata protonMeta `json:"metadata"` Content protonContent `json:"content"` } type protonMeta struct { Name string `json:"name"` Note string `json:"note"` } type protonContent struct { // login ItemUsername string `json:"itemUsername"` ItemEmail string `json:"itemEmail"` Password string `json:"password"` Urls []string `json:"urls"` TOTPUri string `json:"totpUri"` // card CardholderName string `json:"cardholderName"` Number string `json:"number"` VerificationNumber string `json:"verificationNumber"` ExpirationDate string `json:"expirationDate"` // identity FullName string `json:"fullName"` Email string `json:"email"` Phone string `json:"phoneNumber"` // alias AliasEmail string `json:"aliasEmail"` } func parseProtonJSON(content []byte) ([]VaultData, bool) { var pe protonExport if err := json.Unmarshal(content, &pe); err != nil || len(pe.Vaults) == 0 { return nil, false } var entries []VaultData for _, vault := range pe.Vaults { for _, item := range vault.Items { d := item.Data vd := VaultData{ Title: d.Metadata.Name, Notes: d.Metadata.Note, URLs: d.Content.Urls, } // Pick best username: itemUsername, itemEmail, or email username := d.Content.ItemUsername if username == "" { username = d.Content.ItemEmail } switch d.Type { case "login": vd.Type = "credential" vd.Fields = append(vd.Fields, VaultField{Label: "Username", Value: username, Kind: "text"}) vd.Fields = append(vd.Fields, VaultField{Label: "Password", Value: d.Content.Password, Kind: "password"}) if d.Content.TOTPUri != "" { vd.Fields = append(vd.Fields, VaultField{Label: "TOTP Seed", Value: d.Content.TOTPUri, Kind: "totp"}) } case "creditCard": vd.Type = "card" vd.Fields = append(vd.Fields, VaultField{Label: "Cardholder", Value: d.Content.CardholderName, Kind: "text"}) vd.Fields = append(vd.Fields, VaultField{Label: "Number", Value: d.Content.Number, Kind: "text", L2: true}) vd.Fields = append(vd.Fields, VaultField{Label: "CVV", Value: d.Content.VerificationNumber, Kind: "text", L2: true}) vd.Fields = append(vd.Fields, VaultField{Label: "Expiry", Value: d.Content.ExpirationDate, Kind: "text"}) case "identity": vd.Type = "identity" addF := func(l, v string) { if v != "" { vd.Fields = append(vd.Fields, VaultField{Label: l, Value: v, Kind: "text"}) } } addF("Full Name", d.Content.FullName) addF("Email", d.Content.Email) addF("Phone", d.Content.Phone) case "alias": continue // Proton-specific email alias — not a vault entry case "note": vd.Type = "note" vd.Fields = append(vd.Fields, VaultField{Label: "Content", Value: d.Metadata.Note, Kind: "text"}) vd.Notes = "" default: vd.Type = "note" } if item.ModifyTime > 0 { vd.SourceModified = item.ModifyTime } entries = append(entries, vd) } } return entries, len(entries) > 0 }