2025-02-07 23:48:54 +01:00

226 lines
6.2 KiB
Go

// Copyright (C) 2020 Michael J. Fromberger. All Rights Reserved.
// Package otpauth handles the URL format used to specify OTP parameters.
//
// This package conforms to the specification at:
// https://github.com/google/google-authenticator/wiki/Key-Uri-Format
//
// The general form of an OTP URL is:
//
// otpauth://TYPE/LABEL?PARAMETERS
package otpauth
import (
"encoding/base32"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/creachadair/otp"
)
const (
defaultAlgorithm = "SHA1"
defaultDigits = 6
defaultPeriod = 30
)
// A URL contains the parsed representation of an otpauth URL.
type URL struct {
Type string // normalized to lowercase, e.g., "totp"
Issuer string // also called "provider" in some docs
Account string // without provider prefix
RawSecret string // base32-encoded, no padding
Algorithm string // normalized to uppercase; default is "SHA1"
Digits int // default is 6
Period int // in seconds; default is 30
Counter uint64
}
// Secret parses the contents of the RawSecret field.
func (u *URL) Secret() ([]byte, error) { return otp.ParseKey(u.RawSecret) }
// SetSecret encodes key as base32 and updates the RawSecret field.
func (u *URL) SetSecret(key []byte) {
enc := base32.StdEncoding.EncodeToString(key)
u.RawSecret = strings.TrimRight(enc, "=")
}
// String converts u to a URL in the standard encoding.
func (u *URL) String() string {
var sb strings.Builder
sb.WriteString("otpauth://")
typ := strings.ToLower(u.Type)
sb.WriteString(typ)
sb.WriteByte('/')
sb.WriteString(u.labelString())
// Encode parameters if there are any non-default values.
var params []string
if a := strings.ToUpper(u.Algorithm); a != "" && a != "SHA1" {
params = append(params, "algorithm="+queryEscape(a))
}
if c := u.Counter; c > 0 || typ == "hotp" {
params = append(params, "counter="+strconv.FormatUint(c, 10))
}
if d := u.Digits; d > 0 && d != defaultDigits {
params = append(params, "digits="+strconv.Itoa(d))
}
if o := u.Issuer; o != "" {
params = append(params, "issuer="+queryEscape(o))
}
if p := u.Period; p > 0 && p != defaultPeriod {
params = append(params, "period="+strconv.Itoa(p))
}
if s := u.RawSecret; s != "" {
enc := strings.ToUpper(strings.Join(strings.Fields(strings.TrimRight(s, "=")), ""))
params = append(params, "secret="+queryEscape(enc))
}
if len(params) != 0 {
sb.WriteByte('?')
sb.WriteString(strings.Join(params, "&"))
}
return sb.String()
}
// UnmarshalText implements the encoding.TextUnmarshaler interface.
// It expects its input to be a URL in the standard encoding.
func (u *URL) UnmarshalText(data []byte) error {
p, err := ParseURL(string(data))
if err != nil {
return err
}
*u = *p // a shallow copy is safe, there are no pointers
return nil
}
// MarshalText implemens the encoding.TextMarshaler interface.
// It emits the same URL string produced by the String method.
func (u *URL) MarshalText() ([]byte, error) {
return []byte(u.String()), nil
}
func (u *URL) labelString() string {
label := url.PathEscape(u.Account)
if u.Issuer != "" {
return url.PathEscape(u.Issuer) + ":" + label
}
return label
}
func (u *URL) parseLabel(s string) error {
account, err := url.PathUnescape(s)
if err != nil {
return err
}
if i := strings.Index(account, ":"); i >= 0 {
u.Issuer = strings.TrimSpace(account[:i])
if u.Issuer == "" {
return errors.New("empty issuer")
}
account = account[i+1:]
}
u.Account = strings.TrimSpace(account)
if u.Account == "" {
return errors.New("empty account name")
}
return nil
}
// ParseURL parses s as a URL in the otpauth scheme.
//
// The input may omit a scheme, but if present the scheme must be otpauth://.
// The parser will report an error for invalid syntax, including unknown URL
// parameters, but does not otherwise validate the results. In particular, the
// values of the Type and Algorithm fields are not checked.
//
// Fields of the URL corresponding to unset parameters are populated with
// default values as described on the URL struct. If a different issuer is set
// on the label and in the parameters, the parameter takes priority.
func ParseURL(s string) (*URL, error) {
// A scheme is not required, but if present it must be "otpauth".
if ps := strings.SplitN(s, "://", 2); len(ps) == 2 {
if ps[0] != "otpauth" {
return nil, fmt.Errorf("invalid scheme %q", ps[0])
}
s = ps[1] // trim scheme prefix
}
// Extract TYPE/LABEL and optional PARAMS.
var typeLabel, params string
if ps := strings.SplitN(s, "?", 2); len(ps) == 2 {
typeLabel, params = ps[0], ps[1]
} else {
typeLabel = ps[0]
}
// Require that type and label are both present and non-empty.
// Note that the "//" authority marker is treated as optional.
ps := strings.SplitN(strings.TrimPrefix(typeLabel, "//"), "/", 2) // [TYPE, LABEL]
if len(ps) != 2 || ps[0] == "" || ps[1] == "" {
return nil, errors.New("invalid type/label")
}
out := &URL{
Type: strings.ToLower(ps[0]),
Algorithm: defaultAlgorithm,
Digits: defaultDigits,
Period: defaultPeriod,
}
if err := out.parseLabel(ps[1]); err != nil {
return nil, fmt.Errorf("invalid label: %v", err)
}
if params == "" {
return out, nil
}
// Parse URL parameters.
for _, param := range strings.Split(params, "&") {
ps := strings.SplitN(param, "=", 2)
if len(ps) == 1 {
ps = append(ps, "") // check value below
}
value, err := url.QueryUnescape(ps[1])
if err != nil {
return nil, fmt.Errorf("invalid value: %v", err)
}
// Handle string-valued parameters.
if ps[0] == "algorithm" {
out.Algorithm = strings.ToUpper(value)
continue
} else if ps[0] == "issuer" {
out.Issuer = value
continue
} else if ps[0] == "secret" {
out.RawSecret = value
continue
}
// All other valid parameters require an integer argument.
// Defer error reporting so we report an unknown field first.
n, err := strconv.ParseUint(value, 10, 64)
switch ps[0] {
case "counter":
out.Counter = n
case "digits":
out.Digits = int(n)
case "period":
out.Period = int(n)
default:
return nil, fmt.Errorf("invalid parameter %q", ps[0])
}
if err != nil {
return nil, fmt.Errorf("invalid integer value %q", value)
}
}
return out, nil
}
func queryEscape(s string) string {
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}