8000 Add reverse file with otpauth://... URLs to migration QR codes by idachev · Pull Request #39 · dim13/otpauth · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Add reverse file with otpauth://... URLs to migration QR codes #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter 8000

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
otpauth

migration.bin

.history

25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
36 changes: 29 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
25 changes: 17 additions & 8 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"log"
"os"
"path/filepath"

"github.com/dim13/otpauth/migration"
)

Expand All @@ -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()

Expand All @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion migration/evaluate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion migration/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
181 changes: 181 additions & 0 deletions migration/otpauth.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
0