clavitor/clavis/clavis-vault/lib/importers.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
}