diff --git a/.gitignore b/.gitignore index ba77735..d4e746b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ +otpauth + migration.bin + +.history + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..05a5778 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,25 @@ +# 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 .` +- Run linter: `golangci-lint run` + +## 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 diff --git a/README.md b/README.md index 0910354..6f29b1b 100644 --- a/README.md +++ b/README.md @@ -16,19 +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) + -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 @@ -62,6 +68,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 -migration-batch-img-prefix batch -migration-batch-size 10 +``` + +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..a9ad4e5 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,6 @@ import ( "log" "os" "path/filepath" - "github.com/dim13/otpauth/migration" ) @@ -33,13 +32,16 @@ func migrationData(fname, link string) ([]byte, error) { 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") + 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)") ) flag.Parse() @@ -49,6 +51,13 @@ func main() { } } + if *otpauthUrlsFile != "" { + if err := migration.ProcessOtpauthFile(*otpauthUrlsFile, *workdir, *migrationBatchImgPrefix, *migrationBatchSize); err != nil { + log.Fatal("processing input file: ", err) + } + return + } + cacheFile := filepath.Join(*workdir, cacheFilename) data, err := migrationData(cacheFile, *link) if err != nil { 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 new file mode 100644 index 0000000..638cd69 --- /dev/null +++ b/migration/otpauth.go @@ -0,0 +1,181 @@ +package migration + +import ( + "encoding/base32" + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + + "google.golang.org/protobuf/proto" +) + +func ProcessOtpauthFile(filePath, workdir, batchPrefix string, batchSize int) error { + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("reading input file: %w", err) + } + + 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") + } + + batchCount := (len(urls) + batchSize - 1) / batchSize + + 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 + } + + data, err := proto.Marshal(payload) + if err != nil { + fmt.Printf("Error marshaling payload for batch %d: %v\n", batchIdx+1, err) + continue + } + + 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)) + } + + 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 +} + +func CreateMigrationPayload(urls []string, batchIndex, batchCount int) (*Payload, error) { + payload := &Payload{ + Version: 1, + BatchSize: int32(batchCount), + BatchIndex: int32(batchIndex), + BatchId: 1, + } + + for _, urlStr := range urls { + 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 + } + + values := u.Query() + secretBase32 := values.Get("secret") + if secretBase32 == "" { + fmt.Printf("SKIPPING - Missing secret parameter in URL: %s\n", urlStr) + continue + } + + secretBase32 = AddBase32Padding(secretBase32) + + secret, err := base32.StdEncoding.DecodeString(secretBase32) + if err != nil { + 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) + } + + param := &Payload_OtpParameters{ + Secret: secret, + Name: u.Path, + Issuer: values.Get("issuer"), + } + + 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 + } + + 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 + } + + 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 + } + + param.Name = strings.TrimPrefix(param.Name, "/") + + 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 +} + +func AddBase32Padding(s string) string { + if len(s)%8 == 0 { + return s + } + + padLen := 8 - (len(s) % 8) + return s + strings.Repeat("=", padLen) +} 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