From 5ec8fe9b9f8622b9e4ece0d1b7d3cc46b6559d95 Mon Sep 17 00:00:00 2001 From: Ivan Dachev Date: Wed, 19 Mar 2025 04:49:26 +0200 Subject: [PATCH 1/7] Initial AI impl --- .gitignore | 3 + README.md | 20 +++++ main.go | 223 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 239 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index ba77735..7d4cfc8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ migration.bin + +.history + diff --git a/README.md b/README.md index 0910354..c4f384e 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ to plain [otpauth links](https://github.com/google/google-authenticator/wiki/Key generate QR-codes (optauth://) -rev reverse QR-code (otpauth-migration://) + -file string + input file with otpauth:// URLs (one per line) + -batch-prefix string + prefix for batch QR code filenames (default "batch") ``` ## Example @@ -62,6 +66,22 @@ Will generate: ![Example](images/example.png) +### Process a File with otpauth URLs + +You can also process a file containing multiple otpauth URLs (one per line) and generate QR codes for batches of 10 URLs: + +``` +~/go/bin/otpauth -file urls.txt -workdir output -batch-prefix batch +``` + +This will: +1. Read all otpauth:// URLs from the file +2. Group them in batches of 10 +3. Create migration payloads for each batch +4. Generate QR codes in the output directory with names like batch_1.png, batch_2.png, etc. + +The generated QR codes can be scanned by Google Authenticator to import the accounts in each batch. + ### Serve http ``` ~/go/bin/otpauth -http=localhost:6060 -link "otpauth-migration://offline?data=CjEKCkhlbGxvId6tvu8SGEV4YW1wbGU6YWxpY2VAZ29vZ2xlLmNvbRoHRXhhbXBsZTAC" diff --git a/main.go b/main.go index 52540f6..58da8f9 100644 --- a/main.go +++ b/main.go @@ -4,13 +4,18 @@ package main import ( + "encoding/base32" "flag" "fmt" "log" + "net/url" "os" "path/filepath" + "strconv" + "strings" "github.com/dim13/otpauth/migration" + "google.golang.org/protobuf/proto" ) const ( @@ -31,15 +36,211 @@ func migrationData(fname, link string) ([]byte, error) { return data, os.WriteFile(fname, data, 0600) } +// processOtpauthFile reads a file containing otpauth:// URLs (one per line), +// groups them in batches, creates migration payloads for each batch, +// and generates QR codes. +func processOtpauthFile(filePath, workdir, batchPrefix string, batchSize int) error { + // Read the file content + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("reading input file: %w", err) + } + + // Split the content by lines and filter empty lines + var urls []string + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + if line != "" && strings.HasPrefix(line, "otpauth://") { + urls = append(urls, line) + } + } + + if len(urls) == 0 { + return fmt.Errorf("no valid otpauth:// URLs found in file") + } + + // Process URLs in batches + batchCount := (len(urls) + batchSize - 1) / batchSize // Ceiling division + + fmt.Printf("Found %d otpauth URLs, creating %d batches\n", len(urls), batchCount) + + var processedBatches int + var totalProcessedURLs int + + for batchIdx := 0; batchIdx < batchCount; batchIdx++ { + start := batchIdx * batchSize + end := (batchIdx + 1) * batchSize + if end > len(urls) { + end = len(urls) + } + + batchUrls := urls[start:end] + payload, err := createMigrationPayload(batchUrls, batchIdx, batchCount) + if err != nil { + fmt.Printf("Error in batch %d: %v\n", batchIdx+1, err) + continue + } + + if len(payload.OtpParameters) == 0 { + fmt.Printf("Skipping batch %d: No valid OTP parameters found\n", batchIdx+1) + continue + } + + // Marshal the payload to protobuf + data, err := proto.Marshal(payload) + if err != nil { + fmt.Printf("Error marshaling payload for batch %d: %v\n", batchIdx+1, err) + continue + } + + // Create QR code for the batch + fileName := fmt.Sprintf("%s_%d.png", batchPrefix, batchIdx+1) + filePath := filepath.Join(workdir, fileName) + if err := migration.PNG(filePath, migration.URL(data)); err != nil { + fmt.Printf("Error generating QR code for batch %d: %v\n", batchIdx+1, err) + continue + } + + processedBatches++ + totalProcessedURLs += len(payload.OtpParameters) + fmt.Printf("Created batch %d QR code: %s (with %d valid URLs)\n", + batchIdx+1, filePath, len(payload.OtpParameters)) + } + + // Print summary + fmt.Printf("\n=== SUMMARY ===\n") + fmt.Printf("Total URLs found: %d\n", len(urls)) + fmt.Printf("Valid URLs processed: %d\n", totalProcessedURLs) + fmt.Printf("Skipped URLs: %d\n", len(urls) - totalProcessedURLs) + fmt.Printf("Successfully processed batches: %d out of %d\n", processedBatches, batchCount) + + return nil +} + +// createMigrationPayload converts a list of otpauth:// URLs into a migration.Payload +// Returns the payload and a slice of invalid URLs +func createMigrationPayload(urls []string, batchIndex, batchCount int) (*migration.Payload, error) { + payload := &migration.Payload{ + Version: 1, + BatchSize: int32(batchCount), + BatchIndex: int32(batchIndex), + BatchId: 1, // Using a default batch ID + } + + for _, urlStr := range urls { + // Parse the otpauth URL + u, err := url.Parse(urlStr) + if err != nil { + fmt.Printf("SKIPPING - Invalid URL format: %s\nError: %v\n", urlStr, err) + continue + } + + if u.Scheme != "otpauth" { + fmt.Printf("SKIPPING - Invalid URL scheme: %s\nURL: %s\n", u.Scheme, urlStr) + continue + } + + // Extract parameters + values := u.Query() + secretBase32 := values.Get("secret") + if secretBase32 == "" { + fmt.Printf("SKIPPING - Missing secret parameter in URL: %s\n", urlStr) + continue + } + + // Add padding if needed (Google Authenticator uses NoPadding) + secretBase32 = addBase32Padding(secretBase32) + + // Try to decode secret from base32 + secret, err := base32.StdEncoding.DecodeString(secretBase32) + if err != nil { + // If Base32 decoding fails, use the secret as plain text + fmt.Printf("WARNING - Invalid Base32 secret in URL: %s\nError: %v\n", urlStr, err) + fmt.Printf("Using secret as plain text instead of Base32-encoded data\n") + secret = []byte(secretBase32) // Use the raw string as the secret + } + + // Create OtpParameters + param := &migration.Payload_OtpParameters{ + Secret: secret, + Name: u.Path, + Issuer: values.Get("issuer"), + } + + // Set algorithm + switch strings.ToUpper(values.Get("algorithm")) { + case "SHA1", "": + param.Algorithm = migration.Payload_OtpParameters_ALGORITHM_SHA1 + case "SHA256": + param.Algorithm = migration.Payload_OtpParameters_ALGORITHM_SHA256 + case "SHA512": + param.Algorithm = migration.Payload_OtpParameters_ALGORITHM_SHA512 + case "MD5": + param.Algorithm = migration.Payload_OtpParameters_ALGORITHM_MD5 + default: + param.Algorithm = migration.Payload_OtpParameters_ALGORITHM_SHA1 + } + + // Set digit count + switch values.Get("digits") { + case "6", "": + param.Digits = migration.Payload_OtpParameters_DIGIT_COUNT_SIX + case "8": + param.Digits = migration.Payload_OtpParameters_DIGIT_COUNT_EIGHT + default: + param.Digits = migration.Payload_OtpParameters_DIGIT_COUNT_SIX + } + + // Set OTP type + switch u.Host { + case "hotp": + param.Type = migration.Payload_OtpParameters_OTP_TYPE_HOTP + counter, _ := strconv.ParseUint(values.Get("counter"), 10, 64) + param.Counter = counter + case "totp", "": + param.Type = migration.Payload_OtpParameters_OTP_TYPE_TOTP + default: + param.Type = migration.Payload_OtpParameters_OTP_TYPE_TOTP + } + + // If path starts with a slash, remove it + if strings.HasPrefix(param.Name, "/") { + param.Name = param.Name[1:] + } + + // If name starts with the issuer name followed by a colon, remove it + if param.Issuer != "" && strings.HasPrefix(param.Name, param.Issuer+":") { + param.Name = param.Name[len(param.Issuer)+1:] + } + + payload.OtpParameters = append(payload.OtpParameters, param) + } + + return payload, nil +} + +// addBase32Padding adds padding to a base32 string if needed +func addBase32Padding(s string) string { + if len(s)%8 == 0 { + return s + } + + padLen := 8 - (len(s) % 8) + return s + strings.Repeat("=", padLen) +} + func main() { var ( - link = flag.String("link", "", "migration link (required)") - workdir = flag.String("workdir", "", "working directory") - http = flag.String("http", "", "serve http (e.g. localhost:6060)") - eval = flag.Bool("eval", false, "evaluate otps") - qr = flag.Bool("qr", false, "generate QR-codes (optauth://)") - rev = flag.Bool("rev", false, "reverse QR-code (otpauth-migration://)") - info = flag.Bool("info", false, "display batch info") + link = flag.String("link", "", "migration link (required)") + workdir = flag.String("workdir", "", "working directory") + http = flag.String("http", "", "serve http (e.g. localhost:6060)") + eval = flag.Bool("eval", false, "evaluate otps") + qr = flag.Bool("qr", false, "generate QR-codes (optauth://)") + rev = flag.Bool("rev", false, "reverse QR-code (otpauth-migration://)") + info = flag.Bool("info", false, "display batch info") + inputFile = flag.String("file", "", "input file with otpauth:// URLs (one per line)") + batchPrefix = flag.String("batch-prefix", "batch", "prefix for batch QR code filenames") + batchSize = flag.Int("batch-size", 7, "number of URLs to include in each batch (default: 7)") ) flag.Parse() @@ -49,6 +250,14 @@ func main() { } } + // Handle input file with otpauth URLs + if *inputFile != "" { + if err := processOtpauthFile(*inputFile, *workdir, *batchPrefix, *batchSize); err != nil { + log.Fatal("processing input file: ", err) + } + return + } + cacheFile := filepath.Join(*workdir, cacheFilename) data, err := migrationData(cacheFile, *link) if err != nil { From 2c93946e4ef41f741b186c47f72bee993da3f4c6 Mon Sep 17 00:00:00 2001 From: Ivan Dachev Date: Wed, 19 Mar 2025 04:55:37 +0200 Subject: [PATCH 2/7] Moved to otpauth.go --- .gitignore | 2 + main.go | 201 +---------------------------------------- migration/otpauth.go | 206 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 199 deletions(-) create mode 100644 migration/otpauth.go diff --git a/.gitignore b/.gitignore index 7d4cfc8..d4e746b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +otpauth + migration.bin .history diff --git a/main.go b/main.go index 58da8f9..d0259dd 100644 --- a/main.go +++ b/main.go @@ -4,18 +4,13 @@ package main import ( - "encoding/base32" "flag" "fmt" "log" - "net/url" "os" "path/filepath" - "strconv" - "strings" - + "github.com/dim13/otpauth/migration" - "google.golang.org/protobuf/proto" ) const ( @@ -36,198 +31,6 @@ func migrationData(fname, link string) ([]byte, error) { return data, os.WriteFile(fname, data, 0600) } -// processOtpauthFile reads a file containing otpauth:// URLs (one per line), -// groups them in batches, creates migration payloads for each batch, -// and generates QR codes. -func processOtpauthFile(filePath, workdir, batchPrefix string, batchSize int) error { - // Read the file content - content, err := os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("reading input file: %w", err) - } - - // Split the content by lines and filter empty lines - var urls []string - for _, line := range strings.Split(string(content), "\n") { - line = strings.TrimSpace(line) - if line != "" && strings.HasPrefix(line, "otpauth://") { - urls = append(urls, line) - } - } - - if len(urls) == 0 { - return fmt.Errorf("no valid otpauth:// URLs found in file") - } - - // Process URLs in batches - batchCount := (len(urls) + batchSize - 1) / batchSize // Ceiling division - - fmt.Printf("Found %d otpauth URLs, creating %d batches\n", len(urls), batchCount) - - var processedBatches int - var totalProcessedURLs int - - for batchIdx := 0; batchIdx < batchCount; batchIdx++ { - start := batchIdx * batchSize - end := (batchIdx + 1) * batchSize - if end > len(urls) { - end = len(urls) - } - - batchUrls := urls[start:end] - payload, err := createMigrationPayload(batchUrls, batchIdx, batchCount) - if err != nil { - fmt.Printf("Error in batch %d: %v\n", batchIdx+1, err) - continue - } - - if len(payload.OtpParameters) == 0 { - fmt.Printf("Skipping batch %d: No valid OTP parameters found\n", batchIdx+1) - continue - } - - // Marshal the payload to protobuf - data, err := proto.Marshal(payload) - if err != nil { - fmt.Printf("Error marshaling payload for batch %d: %v\n", batchIdx+1, err) - continue - } - - // Create QR code for the batch - fileName := fmt.Sprintf("%s_%d.png", batchPrefix, batchIdx+1) - filePath := filepath.Join(workdir, fileName) - if err := migration.PNG(filePath, migration.URL(data)); err != nil { - fmt.Printf("Error generating QR code for batch %d: %v\n", batchIdx+1, err) - continue - } - - processedBatches++ - totalProcessedURLs += len(payload.OtpParameters) - fmt.Printf("Created batch %d QR code: %s (with %d valid URLs)\n", - batchIdx+1, filePath, len(payload.OtpParameters)) - } - - // Print summary - fmt.Printf("\n=== SUMMARY ===\n") - fmt.Printf("Total URLs found: %d\n", len(urls)) - fmt.Printf("Valid URLs processed: %d\n", totalProcessedURLs) - fmt.Printf("Skipped URLs: %d\n", len(urls) - totalProcessedURLs) - fmt.Printf("Successfully processed batches: %d out of %d\n", processedBatches, batchCount) - - return nil -} - -// createMigrationPayload converts a list of otpauth:// URLs into a migration.Payload -// Returns the payload and a slice of invalid URLs -func createMigrationPayload(urls []string, batchIndex, batchCount int) (*migration.Payload, error) { - payload := &migration.Payload{ - Version: 1, - BatchSize: int32(batchCount), - BatchIndex: int32(batchIndex), - BatchId: 1, // Using a default batch ID - } - - for _, urlStr := range urls { - // Parse the otpauth URL - u, err := url.Parse(urlStr) - if err != nil { - fmt.Printf("SKIPPING - Invalid URL format: %s\nError: %v\n", urlStr, err) - continue - } - - if u.Scheme != "otpauth" { - fmt.Printf("SKIPPING - Invalid URL scheme: %s\nURL: %s\n", u.Scheme, urlStr) - continue - } - - // Extract parameters - values := u.Query() - secretBase32 := values.Get("secret") - if secretBase32 == "" { - fmt.Printf("SKIPPING - Missing secret parameter in URL: %s\n", urlStr) - continue - } - - // Add padding if needed (Google Authenticator uses NoPadding) - secretBase32 = addBase32Padding(secretBase32) - - // Try to decode secret from base32 - secret, err := base32.StdEncoding.DecodeString(secretBase32) - if err != nil { - // If Base32 decoding fails, use the secret as plain text - fmt.Printf("WARNING - Invalid Base32 secret in URL: %s\nError: %v\n", urlStr, err) - fmt.Printf("Using secret as plain text instead of Base32-encoded data\n") - secret = []byte(secretBase32) // Use the raw string as the secret - } - - // Create OtpParameters - param := &migration.Payload_OtpParameters{ - Secret: secret, - Name: u.Path, - Issuer: values.Get("issuer"), - } - - // Set algorithm - switch strings.ToUpper(values.Get("algorithm")) { - case "SHA1", "": - param.Algorithm = migration.Payload_OtpParameters_ALGORITHM_SHA1 - case "SHA256": - param.Algorithm = migration.Payload_OtpParameters_ALGORITHM_SHA256 - case "SHA512": - param.Algorithm = migration.Payload_OtpParameters_ALGORITHM_SHA512 - case "MD5": - param.Algorithm = migration.Payload_OtpParameters_ALGORITHM_MD5 - default: - param.Algorithm = migration.Payload_OtpParameters_ALGORITHM_SHA1 - } - - // Set digit count - switch values.Get("digits") { - case "6", "": - param.Digits = migration.Payload_OtpParameters_DIGIT_COUNT_SIX - case "8": - param.Digits = migration.Payload_OtpParameters_DIGIT_COUNT_EIGHT - default: - param.Digits = migration.Payload_OtpParameters_DIGIT_COUNT_SIX - } - - // Set OTP type - switch u.Host { - case "hotp": - param.Type = migration.Payload_OtpParameters_OTP_TYPE_HOTP - counter, _ := strconv.ParseUint(values.Get("counter"), 10, 64) - param.Counter = counter - case "totp", "": - param.Type = migration.Payload_OtpParameters_OTP_TYPE_TOTP - default: - param.Type = migration.Payload_OtpParameters_OTP_TYPE_TOTP - } - - // If path starts with a slash, remove it - if strings.HasPrefix(param.Name, "/") { - param.Name = param.Name[1:] - } - - // If name starts with the issuer name followed by a colon, remove it - if param.Issuer != "" && strings.HasPrefix(param.Name, param.Issuer+":") { - param.Name = param.Name[len(param.Issuer)+1:] - } - - payload.OtpParameters = append(payload.OtpParameters, param) - } - - return payload, nil -} - -// addBase32Padding adds padding to a base32 string if needed -func addBase32Padding(s string) string { - if len(s)%8 == 0 { - return s - } - - padLen := 8 - (len(s) % 8) - return s + strings.Repeat("=", padLen) -} func main() { var ( @@ -252,7 +55,7 @@ func main() { // Handle input file with otpauth URLs if *inputFile != "" { - if err := processOtpauthFile(*inputFile, *workdir, *batchPrefix, *batchSize); err != nil { + if err := migration.ProcessOtpauthFile(*inputFile, *workdir, *batchPrefix, *batchSize); err != nil { log.Fatal("processing input file: ", err) } return diff --git a/migration/otpauth.go b/migration/otpauth.go new file mode 100644 index 0000000..53b1863 --- /dev/null +++ b/migration/otpauth.go @@ -0,0 +1,206 @@ +package migration + +import ( + "encoding/base32" + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + + "google.golang.org/protobuf/proto" +) + +// ProcessOtpauthFile reads a file containing otpauth:// URLs (one per line), +// groups them in batches, creates migration payloads for each batch, +// and generates QR codes. +func ProcessOtpauthFile(filePath, workdir, batchPrefix string, batchSize int) error { + // Read the file content + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("reading input file: %w", err) + } + + // Split the content by lines and filter empty lines + var urls []string + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + if line != "" && strings.HasPrefix(line, "otpauth://") { + urls = append(urls, line) + } + } + + if len(urls) == 0 { + return fmt.Errorf("no valid otpauth:// URLs found in file") + } + + // Process URLs in batches + batchCount := (len(urls) + batchSize - 1) / batchSize // Ceiling division + + fmt.Printf("Found %d otpauth URLs, creating %d batches\n", len(urls), batchCount) + + var processedBatches int + var totalProcessedURLs int + + for batchIdx := 0; batchIdx < batchCount; batchIdx++ { + start := batchIdx * batchSize + end := (batchIdx + 1) * batchSize + if end > len(urls) { + end = len(urls) + } + + batchUrls := urls[start:end] + payload, err := CreateMigrationPayload(batchUrls, batchIdx, batchCount) + if err != nil { + fmt.Printf("Error in batch %d: %v\n", batchIdx+1, err) + continue + } + + if len(payload.OtpParameters) == 0 { + fmt.Printf("Skipping batch %d: No valid OTP parameters found\n", batchIdx+1) + continue + } + + // Marshal the payload to protobuf + data, err := proto.Marshal(payload) + if err != nil { + fmt.Printf("Error marshaling payload for batch %d: %v\n", batchIdx+1, err) + continue + } + + // Create QR code for the batch + fileName := fmt.Sprintf("%s_%d.png", batchPrefix, batchIdx+1) + filePath := filepath.Join(workdir, fileName) + if err := PNG(filePath, URL(data)); err != nil { + fmt.Printf("Error generating QR code for batch %d: %v\n", batchIdx+1, err) + continue + } + + processedBatches++ + totalProcessedURLs += len(payload.OtpParameters) + fmt.Printf("Created batch %d QR code: %s (with %d valid URLs)\n", + batchIdx+1, filePath, len(payload.OtpParameters)) + } + + // Print summary + fmt.Printf("\n=== SUMMARY ===\n") + fmt.Printf("Total URLs found: %d\n", len(urls)) + fmt.Printf("Valid URLs processed: %d\n", totalProcessedURLs) + fmt.Printf("Skipped URLs: %d\n", len(urls)-totalProcessedURLs) + fmt.Printf("Successfully processed batches: %d out of %d\n", processedBatches, batchCount) + + return nil +} + +// CreateMigrationPayload converts a list of otpauth:// URLs into a migration.Payload +// Returns the payload and any error encountered +func CreateMigrationPayload(urls []string, batchIndex, batchCount int) (*Payload, error) { + payload := &Payload{ + Version: 1, + BatchSize: int32(batchCount), + BatchIndex: int32(batchIndex), + BatchId: 1, // Using a default batch ID + } + + for _, urlStr := range urls { + // Parse the otpauth URL + u, err := url.Parse(urlStr) + if err != nil { + fmt.Printf("SKIPPING - Invalid URL format: %s\nError: %v\n", urlStr, err) + continue + } + + if u.Scheme != "otpauth" { + fmt.Printf("SKIPPING - Invalid URL scheme: %s\nURL: %s\n", u.Scheme, urlStr) + continue + } + + // Extract parameters + values := u.Query() + secretBase32 := values.Get("secret") + if secretBase32 == "" { + fmt.Printf("SKIPPING - Missing secret parameter in URL: %s\n", urlStr) + continue + } + + // Add padding if needed (Google Authenticator uses NoPadding) + secretBase32 = AddBase32Padding(secretBase32) + + // Try to decode secret from base32 + secret, err := base32.StdEncoding.DecodeString(secretBase32) + if err != nil { + // If Base32 decoding fails, use the secret as plain text + fmt.Printf("WARNING - Invalid Base32 secret in URL: %s\nError: %v\n", urlStr, err) + fmt.Printf("Using secret as plain text instead of Base32-encoded data\n") + secret = []byte(secretBase32) // Use the raw string as the secret + } + + // Create OtpParameters + param := &Payload_OtpParameters{ + Secret: secret, + Name: u.Path, + Issuer: values.Get("issuer"), + } + + // Set algorithm + switch strings.ToUpper(values.Get("algorithm")) { + case "SHA1", "": + param.Algorithm = Payload_OtpParameters_ALGORITHM_SHA1 + case "SHA256": + param.Algorithm = Payload_OtpParameters_ALGORITHM_SHA256 + case "SHA512": + param.Algorithm = Payload_OtpParameters_ALGORITHM_SHA512 + case "MD5": + param.Algorithm = Payload_OtpParameters_ALGORITHM_MD5 + default: + param.Algorithm = Payload_OtpParameters_ALGORITHM_SHA1 + } + + // Set digit count + switch values.Get("digits") { + case "6", "": + param.Digits = Payload_OtpParameters_DIGIT_COUNT_SIX + case "8": + param.Digits = Payload_OtpParameters_DIGIT_COUNT_EIGHT + default: + param.Digits = Payload_OtpParameters_DIGIT_COUNT_SIX + } + + // Set OTP type + switch u.Host { + case "hotp": + param.Type = Payload_OtpParameters_OTP_TYPE_HOTP + counter, _ := strconv.ParseUint(values.Get("counter"), 10, 64) + param.Counter = counter + case "totp", "": + param.Type = Payload_OtpParameters_OTP_TYPE_TOTP + default: + param.Type = Payload_OtpParameters_OTP_TYPE_TOTP + } + + // If path starts with a slash, remove it + if strings.HasPrefix(param.Name, "/") { + param.Name = param.Name[1:] + } + + // If name starts with the issuer name followed by a colon, remove it + if param.Issuer != "" && strings.HasPrefix(param.Name, param.Issuer+":") { + param.Name = param.Name[len(param.Issuer)+1:] + } + + payload.OtpParameters = append(payload.OtpParameters, param) + } + + return payload, nil +} + +// AddBase32Padding adds padding to a base32 string if needed +func AddBase32Padding(s string) string { + if len(s)%8 == 0 { + return s + } + + padLen := 8 - (len(s) % 8) + return s + strings.Repeat("=", padLen) +} \ No newline at end of file From ddb138515ffc4fab822df3fcab50b89ab86c68c1 Mon Sep 17 00:00:00 2001 From: Ivan Dachev Date: Wed, 19 Mar 2025 05:03:06 +0200 Subject: [PATCH 3/7] Add CLAUDE.md with build, test and style guidelines (Claude) --- CLAUDE.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eaa9ca3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +# CLAUDE.md - otpauth codebase guidelines + +## Build & Test Commands +- Build: `go build` +- Run tests: `go test ./...` +- Run specific test: `go test ./migration -run TestConvert` +- Run tests with verbose output: `go test -v ./...` +- Check code format: `gofmt -l .` +- Format code: `gofmt -w .` + +## Code Style Guidelines +- Format: Standard Go style (gofmt) +- Imports: Group standard library first, then external packages +- Error handling: Check errors immediately with if err != nil pattern +- Naming: CamelCase for exported names, camelCase for unexported +- Comments: Package comments use // format, function comments explain purpose +- File organization: Each file has a specific focus (migrations, HTTP handlers) +- Error messages: Lowercase, no trailing punctuation +- Testing: Use table-driven tests where applicable + +## Project Structure +- Main package at root with migration logic in separate migration package +- Protobuf definitions in migration.proto +- Web UI served from embedded static resources \ No newline at end of file From 8dc74bb19cca6cad139fdaaf3f2568bcfc5ca994 Mon Sep 17 00:00:00 2001 From: Ivan Dachev Date: Wed, 19 Mar 2025 05:05:33 +0200 Subject: [PATCH 4/7] Remove comments from otpauth.go --- migration/otpauth.go | 31 ++++--------------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/migration/otpauth.go b/migration/otpauth.go index 53b1863..6afb597 100644 --- a/migration/otpauth.go +++ b/migration/otpauth.go @@ -12,17 +12,12 @@ import ( "google.golang.org/protobuf/proto" ) -// ProcessOtpauthFile reads a file containing otpauth:// URLs (one per line), -// groups them in batches, creates migration payloads for each batch, -// and generates QR codes. func ProcessOtpauthFile(filePath, workdir, batchPrefix string, batchSize int) error { - // Read the file content content, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("reading input file: %w", err) } - // Split the content by lines and filter empty lines var urls []string for _, line := range strings.Split(string(content), "\n") { line = strings.TrimSpace(line) @@ -35,8 +30,7 @@ func ProcessOtpauthFile(filePath, workdir, batchPrefix string, batchSize int) er return fmt.Errorf("no valid otpauth:// URLs found in file") } - // Process URLs in batches - batchCount := (len(urls) + batchSize - 1) / batchSize // Ceiling division + batchCount := (len(urls) + batchSize - 1) / batchSize fmt.Printf("Found %d otpauth URLs, creating %d batches\n", len(urls), batchCount) @@ -62,14 +56,12 @@ func ProcessOtpauthFile(filePath, workdir, batchPrefix string, batchSize int) er continue } - // Marshal the payload to protobuf data, err := proto.Marshal(payload) if err != nil { fmt.Printf("Error marshaling payload for batch %d: %v\n", batchIdx+1, err) continue } - // Create QR code for the batch fileName := fmt.Sprintf("%s_%d.png", batchPrefix, batchIdx+1) filePath := filepath.Join(workdir, fileName) if err := PNG(filePath, URL(data)); err != nil { @@ -83,7 +75,6 @@ func ProcessOtpauthFile(filePath, workdir, batchPrefix string, batchSize int) er batchIdx+1, filePath, len(payload.OtpParameters)) } - // Print summary fmt.Printf("\n=== SUMMARY ===\n") fmt.Printf("Total URLs found: %d\n", len(urls)) fmt.Printf("Valid URLs processed: %d\n", totalProcessedURLs) @@ -93,18 +84,15 @@ func ProcessOtpauthFile(filePath, workdir, batchPrefix string, batchSize int) er return nil } -// CreateMigrationPayload converts a list of otpauth:// URLs into a migration.Payload -// Returns the payload and any error encountered func CreateMigrationPayload(urls []string, batchIndex, batchCount int) (*Payload, error) { payload := &Payload{ Version: 1, BatchSize: int32(batchCount), BatchIndex: int32(batchIndex), - BatchId: 1, // Using a default batch ID + BatchId: 1, } for _, urlStr := range urls { - // Parse the otpauth URL u, err := url.Parse(urlStr) if err != nil { fmt.Printf("SKIPPING - Invalid URL format: %s\nError: %v\n", urlStr, err) @@ -116,7 +104,6 @@ func CreateMigrationPayload(urls []string, batchIndex, batchCount int) (*Payload continue } - // Extract parameters values := u.Query() secretBase32 := values.Get("secret") if secretBase32 == "" { @@ -124,26 +111,21 @@ func CreateMigrationPayload(urls []string, batchIndex, batchCount int) (*Payload continue } - // Add padding if needed (Google Authenticator uses NoPadding) secretBase32 = AddBase32Padding(secretBase32) - // Try to decode secret from base32 secret, err := base32.StdEncoding.DecodeString(secretBase32) if err != nil { - // If Base32 decoding fails, use the secret as plain text fmt.Printf("WARNING - Invalid Base32 secret in URL: %s\nError: %v\n", urlStr, err) fmt.Printf("Using secret as plain text instead of Base32-encoded data\n") - secret = []byte(secretBase32) // Use the raw string as the secret + secret = []byte(secretBase32) } - // Create OtpParameters param := &Payload_OtpParameters{ Secret: secret, Name: u.Path, Issuer: values.Get("issuer"), } - // Set algorithm switch strings.ToUpper(values.Get("algorithm")) { case "SHA1", "": param.Algorithm = Payload_OtpParameters_ALGORITHM_SHA1 @@ -157,7 +139,6 @@ func CreateMigrationPayload(urls []string, batchIndex, batchCount int) (*Payload param.Algorithm = Payload_OtpParameters_ALGORITHM_SHA1 } - // Set digit count switch values.Get("digits") { case "6", "": param.Digits = Payload_OtpParameters_DIGIT_COUNT_SIX @@ -167,7 +148,6 @@ func CreateMigrationPayload(urls []string, batchIndex, batchCount int) (*Payload param.Digits = Payload_OtpParameters_DIGIT_COUNT_SIX } - // Set OTP type switch u.Host { case "hotp": param.Type = Payload_OtpParameters_OTP_TYPE_HOTP @@ -179,12 +159,10 @@ func CreateMigrationPayload(urls []string, batchIndex, batchCount int) (*Payload param.Type = Payload_OtpParameters_OTP_TYPE_TOTP } - // If path starts with a slash, remove it if strings.HasPrefix(param.Name, "/") { param.Name = param.Name[1:] } - // If name starts with the issuer name followed by a colon, remove it if param.Issuer != "" && strings.HasPrefix(param.Name, param.Issuer+":") { param.Name = param.Name[len(param.Issuer)+1:] } @@ -195,7 +173,6 @@ func CreateMigrationPayload(urls []string, batchIndex, batchCount int) (*Payload return payload, nil } -// AddBase32Padding adds padding to a base32 string if needed func AddBase32Padding(s string) string { if len(s)%8 == 0 { return s @@ -203,4 +180,4 @@ func AddBase32Padding(s string) string { padLen := 8 - (len(s) % 8) return s + strings.Repeat("=", padLen) -} \ No newline at end of file +} From a3120561ac51c58e93c34550b7a33fdee1cc005c Mon Sep 17 00:00:00 2001 From: Ivan Dachev Date: Wed, 19 Mar 2025 05:23:52 +0200 Subject: [PATCH 5/7] Implemented with tests --- README.md | 24 ++--- main.go | 27 +++--- migration/otpauth_test.go | 181 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 26 deletions(-) create mode 100644 migration/otpauth_test.go diff --git a/README.md b/README.md index c4f384e..6f29b1b 100644 --- a/README.md +++ b/README.md @@ -16,23 +16,25 @@ to plain [otpauth links](https://github.com/google/google-authenticator/wiki/Key ``` -workdir string - working directory to store eventual files (defaults to current one) + working directory to store eventual files (defaults to current one) -eval - evaluate otps + evaluate otps -http string - serve http (e.g. :6060) + serve http (e.g. :6060) -info - display batch info + display batch info -link string - migration link (required) + migration link (required) -qr - generate QR-codes (optauth://) + generate QR-codes (optauth://) -rev - reverse QR-code (otpauth-migration://) + reverse QR-code (otpauth-migration://) -file string - input file with otpauth:// URLs (one per line) - -batch-prefix string - prefix for batch QR code filenames (default "batch") + input file with otpauth:// URLs (one per line) + -migration-batch-img-prefix string + prefix for batch QR code filenames (default "batch") + -migration-batch-size int + number of URLs to include in each batch (default: 7) ``` ## Example @@ -71,7 +73,7 @@ Will generate: You can also process a file containing multiple otpauth URLs (one per line) and generate QR codes for batches of 10 URLs: ``` -~/go/bin/otpauth -file urls.txt -workdir output -batch-prefix batch +~/go/bin/otpauth -file urls.txt -workdir output -migration-batch-img-prefix batch -migration-batch-size 10 ``` This will: diff --git a/main.go b/main.go index d0259dd..de71852 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,6 @@ import ( "log" "os" "path/filepath" - "github.com/dim13/otpauth/migration" ) @@ -31,19 +30,18 @@ func migrationData(fname, link string) ([]byte, error) { return data, os.WriteFile(fname, data, 0600) } - func main() { var ( - link = flag.String("link", "", "migration link (required)") - workdir = flag.String("workdir", "", "working directory") - http = flag.String("http", "", "serve http (e.g. localhost:6060)") - eval = flag.Bool("eval", false, "evaluate otps") - qr = flag.Bool("qr", false, "generate QR-codes (optauth://)") - rev = flag.Bool("rev", false, "reverse QR-code (otpauth-migration://)") - info = flag.Bool("info", false, "display batch info") - inputFile = flag.String("file", "", "input file with otpauth:// URLs (one per line)") - batchPrefix = flag.String("batch-prefix", "batch", "prefix for batch QR code filenames") - batchSize = flag.Int("batch-size", 7, "number of URLs to include in each batch (default: 7)") + link = flag.String("link", "", "migration link (required)") + workdir = flag.String("workdir", "", "working directory") + http = flag.String("http", "", "serve http (e.g. localhost:6060)") + eval = flag.Bool("eval", false, "evaluate otps") + qr = flag.Bool("qr", false, "generate QR-codes (optauth://)") + rev = flag.Bool("rev", false, "reverse QR-code (otpauth-migration://)") + info = flag.Bool("info", false, "display batch info") + otpauthUrlsFile = flag.String("file", "", "input file with otpauth:// URLs (one per line)") + migrationBatchImgPrefix = flag.String("migration-batch-img-prefix", "batch", "prefix for batch QR code filenames") + migrationBatchSize = flag.Int("migration-batch-size", 7, "number of URLs to include in each batch (default: 7)") ) flag.Parse() @@ -53,9 +51,8 @@ func main() { } } - // Handle input file with otpauth URLs - if *inputFile != "" { - if err := migration.ProcessOtpauthFile(*inputFile, *workdir, *batchPrefix, *batchSize); err != nil { + if *otpauthUrlsFile != "" { + if err := migration.ProcessOtpauthFile(*otpauthUrlsFile, *workdir, *migrationBatchImgPrefix, *migrationBatchSize); err != nil { log.Fatal("processing input file: ", err) } return diff --git a/migration/otpauth_test.go b/migration/otpauth_test.go new file mode 100644 index 0000000..de4fbce --- /dev/null +++ b/migration/otpauth_test.go @@ -0,0 +1,181 @@ +package migration + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAddBase32Padding(t *testing.T) { + testCases := []struct { + input string + expected string + }{ + {"", ""}, + {"ABCDEFGH", "ABCDEFGH"}, + {"ABCDEF", "ABCDEF=="}, + {"A", "A======="}, + {"AB", "AB======"}, + {"ABC", "ABC====="}, + {"ABCD", "ABCD===="}, + {"ABCDE", "ABCDE==="}, + {"ABCDEFG", "ABCDEFG="}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + result := AddBase32Padding(tc.input) + if result != tc.expected { + t.Errorf("AddBase32Padding(%q) = %q, want %q", tc.input, result, tc.expected) + } + }) + } +} + +func TestCreateMigrationPayload(t *testing.T) { + testCases := []struct { + name string + urls []string + batchIndex int + batchCount int + expectedSize int + }{ + { + name: "valid URLs", + urls: []string{ + "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + "otpauth://totp/Example:bob@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + }, + batchIndex: 0, + batchCount: 1, + expectedSize: 2, + }, + { + name: "some invalid URLs", + urls: []string{ + "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + "invalid://url", + "otpauth://totp/Missing?issuer=Secret", + }, + batchIndex: 0, + batchCount: 1, + expectedSize: 1, + }, + { + name: "different algorithms and digits", + urls: []string{ + "otpauth://totp/Test1?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1", + "otpauth://totp/Test2?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8", + "otpauth://totp/Test3?secret=JBSWY3DPEHPK3PXP&algorithm=SHA512", + "otpauth://totp/Test4?secret=JBSWY3DPEHPK3PXP&algorithm=MD5", + }, + batchIndex: 0, + batchCount: 1, + expectedSize: 4, + }, + { + name: "totp and hotp mixed", + urls: []string{ + "otpauth://totp/Test1?secret=JBSWY3DPEHPK3PXP", + "otpauth://hotp/Test2?secret=JBSWY3DPEHPK3PXP&counter=10", + }, + batchIndex: 0, + batchCount: 1, + expectedSize: 2, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + payload, err := CreateMigrationPayload(tc.urls, tc.batchIndex, tc.batchCount) + if err != nil { + t.Fatalf("CreateMigrationPayload failed: %v", err) + } + + if len(payload.OtpParameters) != tc.expectedSize { + t.Errorf("Expected %d parameters, got %d", tc.expectedSize, len(payload.OtpParameters)) + } + + if payload.BatchIndex != int32(tc.batchIndex) { + t.Errorf("Expected BatchIndex %d, got %d", tc.batchIndex, payload.BatchIndex) + } + + if payload.BatchSize != int32(tc.batchCount) { + t.Errorf("Expected BatchSize %d, got %d", tc.batchCount, payload.BatchSize) + } + }) + } +} + +func TestProcessOtpauthFile(t *testing.T) { + tempDir, err := os.MkdirTemp("", "otpauth-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + testFile := filepath.Join(tempDir, "test-urls.txt") + + var testURLsLines []string + for i := 1; i <= 15; i++ { + testURLsLines = append(testURLsLines, + "otpauth://totp/Example:user"+string(rune('a'+i-1))+"@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example") + } + testURLsLines = append(testURLsLines, "invalid line") + testURLsLines = append(testURLsLines, "not an otpauth line") + testURLsLines = append(testURLsLines, "otpauth://totp/Missing?issuer=Secret") + + err = os.WriteFile(testFile, []byte(join(testURLsLines, "\n")), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + batchSize := 5 + batchPrefix := "test-batch" + outputDir := filepath.Join(tempDir, "output") + + err = os.MkdirAll(outputDir, 0755) + if err != nil { + t.Fatalf("Failed to create output dir: %v", err) + } + + err = ProcessOtpauthFile(testFile, outputDir, batchPrefix, batchSize) + if err != nil { + t.Fatalf("ProcessOtpauthFile failed: %v", err) + } + + if _, err := os.Stat(outputDir); os.IsNotExist(err) { + t.Errorf("Output directory was not created") + } + + for i := 1; i <= 3; i++ { + batchFile := filepath.Join(outputDir, "test-batch_"+itoa(i)+".png") + if _, err := os.Stat(batchFile); os.IsNotExist(err) { + t.Errorf("Batch %d file was not created", i) + } + } +} + +func join(elements []string, separator string) string { + var result string + for i, element := range elements { + if i > 0 { + result += separator + } + result += element + } + return result +} + +func itoa(i int) string { + digits := "0123456789" + if i == 0 { + return "0" + } + var result string + for i > 0 { + result = string(digits[i%10]) + result + i /= 10 + } + return result +} \ No newline at end of file From f432bb7de567c8f67c57c4aabde8616766ef65c2 Mon Sep 17 00:00:00 2001 From: Ivan Dachev Date: Wed, 19 Mar 2025 05:35:47 +0200 Subject: [PATCH 6/7] Fix linter issues and update CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix error handling for binary.Write in evaluate.go - Add error handling for w.Write in handler.go - Replace if condition with strings.TrimPrefix in otpauth.go - Add golangci-lint command to CLAUDE.md 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 1 + migration/evaluate.go | 4 +++- migration/handler.go | 5 ++++- migration/otpauth.go | 4 +--- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eaa9ca3..05a5778 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ - Run tests with verbose output: `go test -v ./...` - Check code format: `gofmt -l .` - Format code: `gofmt -w .` +- Run linter: `golangci-lint run` ## Code Style Guidelines - Format: Standard Go style (gofmt) diff --git a/migration/evaluate.go b/migration/evaluate.go index 0ee3f46..7c955c1 100644 --- a/migration/evaluate.go +++ b/migration/evaluate.go @@ -32,7 +32,9 @@ func (op *Payload_OtpParameters) Seconds() float64 { // Evaluate OTP parameters func (op *Payload_OtpParameters) Evaluate() int { h := hmac.New(op.Algorithm.Hash(), op.Secret) - binary.Write(h, binary.BigEndian, op.Type.Count(op)) + if err := binary.Write(h, binary.BigEndian, op.Type.Count(op)); err != nil { + return 0 + } hashed := h.Sum(nil) offset := hashed[h.Size()-1] & 15 result := binary.BigEndian.Uint32(hashed[offset:]) & (1<<31 - 1) diff --git a/migration/handler.go b/migration/handler.go index ef335a7..1fbb15d 100644 --- a/migration/handler.go +++ b/migration/handler.go @@ -8,5 +8,8 @@ func (op *Payload_OtpParameters) ServeHTTP(w http.ResponseWriter, r *http.Reques http.Error(w, err.Error(), http.StatusInternalServerError) return } - w.Write(pic) + _, err = w.Write(pic) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } diff --git a/migration/otpauth.go b/migration/otpauth.go index 6afb597..638cd69 100644 --- a/migration/otpauth.go +++ b/migration/otpauth.go @@ -159,9 +159,7 @@ func CreateMigrationPayload(urls []string, batchIndex, batchCount int) (*Payload param.Type = Payload_OtpParameters_OTP_TYPE_TOTP } - if strings.HasPrefix(param.Name, "/") { - param.Name = param.Name[1:] - } + param.Name = strings.TrimPrefix(param.Name, "/") if param.Issuer != "" && strings.HasPrefix(param.Name, param.Issuer+":") { param.Name = param.Name[len(param.Issuer)+1:] From df7754fd812a7f7b70fbd64795774cea50f8c417 Mon Sep 17 00:00:00 2001 From: Ivan Dachev Date: Wed, 19 Mar 2025 05:43:29 +0200 Subject: [PATCH 7/7] Fixed command name --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index de71852..a9ad4e5 100644 --- a/main.go +++ b/main.go @@ -39,7 +39,7 @@ func main() { qr = flag.Bool("qr", false, "generate QR-codes (optauth://)") rev = flag.Bool("rev", false, "reverse QR-code (otpauth-migration://)") info = flag.Bool("info", false, "display batch info") - otpauthUrlsFile = flag.String("file", "", "input file with otpauth:// URLs (one per line)") + otpauthUrlsFile = flag.String("otpauth-file", "", "input file with otpauth:// URLs (one per line)") migrationBatchImgPrefix = flag.String("migration-batch-img-prefix", "batch", "prefix for batch QR code filenames") migrationBatchSize = flag.Int("migration-batch-size", 7, "number of URLs to include in each batch (default: 7)") )