From e48660930edda4e5803827e586e6810352538e19 Mon Sep 17 00:00:00 2001 From: Till Klocke Date: Thu, 28 Sep 2023 16:22:56 +0200 Subject: [PATCH] Initial commit, seems to do what it needs to do --- andotp/andotp.go | 33 ++++++ cli/converter.go | 40 +++++++ converter.go | 82 ++++++++++++++ go.mod | 5 + go.sum | 2 + twofas/2fas.go | 85 ++++++++++++++ twofas/servicetype.go | 253 ++++++++++++++++++++++++++++++++++++++++++ twofas/tint.go | 17 +++ 8 files changed, 517 insertions(+) create mode 100644 andotp/andotp.go create mode 100644 cli/converter.go create mode 100644 converter.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 twofas/2fas.go create mode 100644 twofas/servicetype.go create mode 100644 twofas/tint.go diff --git a/andotp/andotp.go b/andotp/andotp.go new file mode 100644 index 0000000..38e210c --- /dev/null +++ b/andotp/andotp.go @@ -0,0 +1,33 @@ +package andotp + +import ( + "encoding/json" + "fmt" + "os" +) + +type Service struct { + Secret string `json:"secret"` + Issuer string `json:"issuer"` + Label string `json:"label"` + Digits int `json:"digits"` + Type string `json:"type"` + Algorithm string `json:"algorithm"` + Thumbnail string `json:"thumbnail"` + LastUsed uint64 `json:"last_used"` + UsedFrequency int `json:"used_frequency"` + Period int `json:"period"` + Tags []string `json:"tags"` +} + +func Import(filepath string) (services []*Service, err error) { + fileBytes, err := os.ReadFile(filepath) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", filepath, err) + } + services = make([]*Service, 0) + if err = json.Unmarshal(fileBytes, &services); err != nil { + return nil, fmt.Errorf("failed to unmarshal file %s: %w", filepath, err) + } + return +} diff --git a/cli/converter.go b/cli/converter.go new file mode 100644 index 0000000..d660eb3 --- /dev/null +++ b/cli/converter.go @@ -0,0 +1,40 @@ +package main + +import ( + "encoding/json" + "flag" + "log" + "os" + + andotpconverter "github.com/dereulenspiegel/andotp-converter" + "github.com/dereulenspiegel/andotp-converter/andotp" +) + +func main() { + flag.Parse() + if len(flag.Args()) != 2 { + log.Fatalf("You need to specify an input and output file") + } + inputFile := flag.Arg(0) + outputFile := flag.Arg(1) + + run(inputFile, outputFile) +} + +func run(inputFile, outputFile string) { + andOtpData, err := andotp.Import(inputFile) + if err != nil { + log.Fatalf("failed to load andOTP data: %s", err) + } + twofasData, err := andotpconverter.FromAndOtpTo2Fas(andOtpData) + if err != nil { + log.Fatalf("failed to convert data to 2FAS format: %s", err) + } + twoFasBytes, err := json.Marshal(twofasData) + if err != nil { + log.Fatalf("failed to marshal 2FAS data: %s", err) + } + if err := os.WriteFile(outputFile, twoFasBytes, 0660); err != nil { + log.Fatalf("failed to write 2FAS data to file %s: %s", outputFile, err) + } +} diff --git a/converter.go b/converter.go new file mode 100644 index 0000000..129a112 --- /dev/null +++ b/converter.go @@ -0,0 +1,82 @@ +package andotpconverter + +import ( + "time" + + "github.com/dereulenspiegel/andotp-converter/andotp" + "github.com/dereulenspiegel/andotp-converter/twofas" + "github.com/google/uuid" +) + +func FromAndOtpTo2Fas(andOTPServices []*andotp.Service) (*twofas.RemoteBackup, error) { + updatedAt := uint64(time.Now().UnixMilli()) + twofasBackup := &twofas.RemoteBackup{ + UpdatedAt: updatedAt, + SchemaVersion: twofas.DEFAULT_SCHEMA_VERSION, + AppVersionCode: twofas.DEFAULT_APP_VERSION_CODE, + AppOrigin: twofas.DEAFULT_APP_ORIGIN, + } + + groupMapping := map[string]twofas.RemoteGroup{} + defaultSource := twofas.DEFAULT_SOURCE + + for i, andOTPService := range andOTPServices { + var groupId *string + if len(andOTPService.Tags) > 0 { + // Use the first tag as group + tag := andOTPService.Tags[0] + if group, exists := groupMapping[tag]; !exists { + group = twofas.RemoteGroup{ + Name: tag, + UpdatedAt: updatedAt, + IsExpanded: true, + ID: uuid.Must(uuid.NewRandom()).String(), + } + groupMapping[tag] = group + groupId = &group.ID + } else { + groupId = &group.ID + } + } + + name := andOTPService.Issuer + if name == "" { + name = andOTPService.Label + } + remoteService := twofas.RemoteService{ + Name: name, + Secret: andOTPService.Secret, + UpdatedAt: andOTPService.LastUsed, + Order: twofas.Order{Position: i}, + Type: thumbnailToServiceType(andOTPService.Thumbnail), + GroupId: groupId, + Otp: twofas.Otp{ + Label: &andOTPService.Label, + Account: &andOTPService.Label, + Issuer: &andOTPService.Issuer, + Digits: &andOTPService.Digits, + Period: &andOTPService.Period, + Algorithm: &andOTPService.Algorithm, + Counter: &andOTPService.UsedFrequency, + TokenType: &andOTPService.Type, + Source: &defaultSource, + }, + } + + twofasBackup.Services = append(twofasBackup.Services, &remoteService) + } + for _, group := range groupMapping { + twofasBackup.Groups = append(twofasBackup.Groups, &group) + } + return twofasBackup, nil +} + +func thumbnailToServiceType(thumbnail string) *twofas.ServiceType { + for _, st := range twofas.ServiceTypes { + if string(st) == thumbnail { + return &st + } + } + unknown := twofas.ServiceType("Unknwown") + return &unknown +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d3fe3ea --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/dereulenspiegel/andotp-converter + +go 1.21.1 + +require github.com/google/uuid v1.3.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a43f94d --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/twofas/2fas.go b/twofas/2fas.go new file mode 100644 index 0000000..325639a --- /dev/null +++ b/twofas/2fas.go @@ -0,0 +1,85 @@ +package twofas + +const ( + DEFAULT_SCHEMA_VERSION = 3 + DEFAULT_APP_VERSION_CODE = 4070000 + DEAFULT_APP_VERSION_NAME = "4.7.0" + DEAFULT_APP_ORIGIN = "android" + + TOKEN_TYPE_TOTP = "TOTP" + + DEFAULT_SOURCE = "Link" + DEFAULT_ICON_COLLECTION = "a5b3fb65-4ec5-43e6-8ec1-49e24ca9e7ad" +) + +type Otp struct { + Link *string `json:"link,omitempty"` + Label *string `json:"label,,omitempty"` + Account *string `json:"account,omitempty"` + Issuer *string `json:"issuer,omitempty"` + Digits *int `json:"digits,omitempty"` + Period *int `json:"period,omitempty"` + Algorithm *string `json:"algorith,omitempty"` + Counter *int `json:"counter,omitempty"` + TokenType *string `json:"tokenType,omitempty"` + Source *string `json:"source,omitempty"` +} + +type Order struct { + Position int `json:"position"` +} + +type Badge struct { + Color Tint `json:"color"` +} + +type Icon struct { + Selected string `json:"selected"` // One of Brand, Label, IconCollection + Brand *Brand `json:"brand,omitempty"` + Label *Label `json:"label,omitempty"` + IconCollection *IconCollection `json:"iconCollection,omitempty"` +} + +type Brand struct { + ID *string // Is a ServiceType +} + +type IconCollection struct { + ID string `json:"id"` +} + +type Label struct { + Text string `json:"text"` + Backgroundcolor Tint `json:"backgroundColor"` +} + +type RemoteService struct { + Name string `json:"name"` + Secret string `json:"secret"` + UpdatedAt uint64 `json:"updatedAt"` + Type *ServiceType `json:"type,omitempty"` //TODO import enum ServiceType somehow + Otp Otp `json:"otp"` + Order Order `json:"order"` + Badge *Badge `json:"badge,omitempty"` + Icon *Icon `json:"icon,omitempty"` + GroupId *string `json:"groupId,omitempty"` +} + +type RemoteGroup struct { + ID string `json:"id"` // Should be a UUIDv4 + Name string `json:"name"` + IsExpanded bool `json:"isExpanded"` + UpdatedAt uint64 `json:"updatedAt"` +} + +type RemoteBackup struct { + Services []*RemoteService `json:"services"` + UpdatedAt uint64 `json:"updatedAt"` + SchemaVersion int `json:"schemaVersion"` //Currently at 3 + AppVersionCode int `json:"appVersionCode"` //TODO find out a valid value + AppOrigin string `json:"appOrigin"` //defaults to android + Groups []*RemoteGroup `json:"groups"` + Account *string `json:"account,omitempty"` + ServicesEncrypted *string `json:"servicesEncrypted,omitempty"` + Reference *string `json:"reference,omitempty"` +} diff --git a/twofas/servicetype.go b/twofas/servicetype.go new file mode 100644 index 0000000..0810048 --- /dev/null +++ b/twofas/servicetype.go @@ -0,0 +1,253 @@ +package twofas + +import "strings" + +type ServiceType string + +var ServiceTypes []ServiceType + +func init() { + lines := strings.Split(typeList, ",\n") + for _, serviceName := range lines { + ServiceTypes = append(ServiceTypes, ServiceType(strings.TrimSpace(serviceName))) + } +} + +// Taken from: https://github.com/twofas/2fas-android/blob/develop/prefs/src/main/java/com/twofasapp/prefs/model/ServiceType.kt +var typeList = ` +Null, + Unknown, + ManuallyAdded, + Amazon, + AngelList, + Atlantiss, + Autodesk, + BeamPro, + Binance, + BitBay, + Bitbucket, + Bitcoinmeester, + Bitfinex, + BitMax, + Bitpay, + BitriseIo, + BitSkins, + Bittrex, + Bitvavo, + Blockchain, + BlockchainsLlc, + Braintree, + BraveRewards, + BtcMarkets, + Buffer, + Chargebee, + Cloudflare, + Coinbase, + Coindeal, + Coinsquare, + CosmicPvp, + CryptoMkt, + Devex, + DigitalOcean, + Discord, + Discourse, + DnsMadeEasy, + Dropbox, + Drupal, + ElectronicArts, + EpicGames, + Evernote, + Facebook, + Fintegri, + Firefox, + FreshDesk, + Gamdom, + GateIo, + Github, + Gitlab, + GoDaddy, + Google, + Heroku, + Hetzner, + Hmrc, + Hootsuite, + HubSpot, + HumbleBundle, + Ifttt, + Instagram, + Jagex, + JamfNow, + JuraElektroapparateAg, + Karatbit, + Kickstarter, + Kraken, + KuCoin, + LastPass, + LinkedIn, + Litebit, + Logingov, + MailChimp, + Mega, + Microsoft, + Minergate, + Myob, + Namecheap, + Netsuite, + NiceHashBuying, + NiceHashLogin, + NiceHashWithdraw, + NintendoAccount, + Onelogin, + OnePassword, + Onet, + OpSkins, + Ovh, + PayPal, + Poloniex, + Preceda, + Proton, + ProtonMail, + Qnap, + Reddit, + Sentry, + Shopify, + Skrill, + Slack, + Snapchat, + Sofi, + Sourceforge, + Stripe, + TeamViewer, + Tebex, + Tibia, + Trello, + Twitter, + TwoFasMobileSecret, + TwoFas, + Ubisoft, + Uphold, + Upwork, + Uscis, + Vk, + Wordfence, + Wordpress, + Xero, + Zendesk, + Zoho, + Nvidia, + Synology, + SynologyAccount, + SynologyDsm, + Adobe, + Twitch, + Bitwarden, + Samsung, + Uber, + Zoom, + Activision, + HomeAssistant, + NordAccount, + TwentyI, + AscendEx, + Backblaze, + Bitpanda, + Gmx, + JetBrains, + Joomla, + Nextcloud, + Opera, + Tumblr, + Unity, + Xing, + Telderi, + Tastyworks, + Gogs, + Gitea, + HurricaneElectric, + Box, + Ubiquiti, + AdGuard, + Intuit, + Razer, + RoboForm, + Oracle, + UptimeRobot, + Pulseway, + Parsec, + Wikijs, + Sony, + Robinhood, + Bybit, + Docker, + Choice, + Wyze, + Fritzbox, + Kayako, + CryptoCom, + CoinList, + FTXUS, + SophosSFOS, + SalesForce, + RockstarGames, + Trimble, + Ring, + PhpMyAdmin, + FSecure, + ArenaNet, + SquareEnix, + HackTheBox, + Plex, + EnZona, + Tesla, + Yahoo, + Aws, + Questrade, + Paxful, + Tmobile, + Windscribe, + Arbeitsagentur, + Patreon, + Bitkub, + CoinDcx, + CoinSpot, + Roblox, + WazirX, + MongoDb, + WhiteBit, + Steam, + Grammarly, + Tiktok, + Vimeo, + Idme, + Norton, + Surfshark, + NextDns, + Pcloud, + TrueNas, + OpenVpn, + AnyDesk, + Proxmox, + Kaspersky, + Ionos, + PyPi, + TradingView, + Coursera, + Figma, + Avast, + Okx, + Nexo, + LinusTechTips, + NoIp, + TrendMicro, + Xda, + WebDe, + Atlassian, + Cisco, + Wargaming, + Allegro, + Faceit, + Etsy, + CashApp, + MercadoLibre, + PlayStation, +` diff --git a/twofas/tint.go b/twofas/tint.go new file mode 100644 index 0000000..2dda5a3 --- /dev/null +++ b/twofas/tint.go @@ -0,0 +1,17 @@ +package twofas + +type Tint string + +var ( + TintDefault = Tint("Default") + TintRed = Tint("Red") + TintOrange = Tint("Orange") + TintYellow = Tint("Yellow") + TintGreen = Tint("Green") + TintTurquoise = Tint("Turquoise") + TintLightBlue = Tint("LightBlue") + TintIndigo = Tint("Indigo") + TintPink = Tint("Pink") + TintPurple = Tint("Purple") + TintBrown = Tint("Brown") +)