354 lines
11 KiB
Go
354 lines
11 KiB
Go
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
|
|
}
|