// Package qr handles otpauth:// QR-code parsing. Image decoding is done // with gozxing (pure Go, no CGO); URL parsing is hand-rolled per the // otpauth:// spec used by Google Authenticator, Authy, and friends. // // https://github.com/google/google-authenticator/wiki/Key-Uri-Format package qr import ( "errors" "fmt" "image" "net/url" "strconv" "strings" "github.com/makiuchi-d/gozxing" "github.com/makiuchi-d/gozxing/qrcode" ) // ErrNoQR is returned when the image does not contain a decodable QR code. var ErrNoQR = errors.New("qr: no QR code found in image") // ErrNotOtpAuth is returned when the QR decodes successfully but does // not contain an otpauth:// URI. var ErrNotOtpAuth = errors.New("qr: decoded text is not an otpauth:// URI") // DecodeImage runs the gozxing QR reader on img and returns the decoded // text. Returns ErrNoQR if no QR pattern was found. func DecodeImage(img image.Image) (string, error) { bmp, err := gozxing.NewBinaryBitmapFromImage(img) if err != nil { return "", fmt.Errorf("qr: bitmap: %w", err) } reader := qrcode.NewQRCodeReader() result, err := reader.Decode(bmp, nil) if err != nil { return "", ErrNoQR } return result.GetText(), nil } // OtpAuth carries the parsed pieces of an otpauth:// URI in the form // the UI needs to create a config.Entry. type OtpAuth struct { // Type is "totp" or "hotp". Type string // Label is the human-readable display name (issuer + ":" + account). Label string // SecretBase32 is the Base32-encoded HMAC secret. SecretBase32 string // Issuer is the optional issuer string ("Google", "GitHub", ...). Issuer string // Algorithm is "SHA1", "SHA256", or "SHA512". Empty = unspecified. Algorithm string // Digits is the OTP length (typically 6). 0 = unspecified. Digits int // Period is the TOTP step in seconds (typically 30). 0 = unspecified. Period int // Counter is the HOTP initial counter. Counter uint64 } // ParseOtpAuth turns an otpauth:// URI into an OtpAuth struct. The // secret is left base32-encoded; callers feed it straight into the // authenticator's Enroll method. func ParseOtpAuth(raw string) (*OtpAuth, error) { const fn = "internal.qr.ParseOtpAuth" raw = strings.TrimSpace(raw) if !strings.HasPrefix(strings.ToLower(raw), "otpauth://") { return nil, ErrNotOtpAuth } u, err := url.Parse(raw) if err != nil { return nil, fmt.Errorf("%s: parse URL: %w", fn, err) } typ := strings.ToLower(u.Host) if typ != "totp" && typ != "hotp" { return nil, fmt.Errorf("%s: unsupported otpauth type %q", fn, typ) } q := u.Query() secret := strings.TrimSpace(q.Get("secret")) if secret == "" { return nil, fmt.Errorf("%s: missing secret", fn) } out := &OtpAuth{ Type: typ, Label: strings.TrimPrefix(u.Path, "/"), SecretBase32: secret, Issuer: q.Get("issuer"), Algorithm: strings.ToUpper(q.Get("algorithm")), } if v := q.Get("digits"); v != "" { if n, err := strconv.Atoi(v); err == nil { out.Digits = n } } if v := q.Get("period"); v != "" { if n, err := strconv.Atoi(v); err == nil { out.Period = n } } if v := q.Get("counter"); v != "" { if n, err := strconv.ParseUint(v, 10, 64); err == nil { out.Counter = n } } return out, nil }