226 lines
6.2 KiB
Go
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")
|
|
}
|