From e8a2eb3765a0f2e38b89db5a1d5fd954e434429d Mon Sep 17 00:00:00 2001 From: zeaslity Date: Wed, 12 Mar 2025 11:27:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20totp=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmii-uav-watchdog-otp/go.mod | 3 + cmii-uav-watchdog-otp/hotp/hotp.go | 238 ++++++++++++++++++++++ cmii-uav-watchdog-otp/internal/encode.go | 35 ++++ cmii-uav-watchdog-otp/otp.go | 248 +++++++++++++++++++++++ cmii-uav-watchdog-otp/totp/totp.go | 210 +++++++++++++++++++ 5 files changed, 734 insertions(+) create mode 100644 cmii-uav-watchdog-otp/go.mod create mode 100644 cmii-uav-watchdog-otp/hotp/hotp.go create mode 100644 cmii-uav-watchdog-otp/internal/encode.go create mode 100644 cmii-uav-watchdog-otp/otp.go create mode 100644 cmii-uav-watchdog-otp/totp/totp.go diff --git a/cmii-uav-watchdog-otp/go.mod b/cmii-uav-watchdog-otp/go.mod new file mode 100644 index 0000000..1088089 --- /dev/null +++ b/cmii-uav-watchdog-otp/go.mod @@ -0,0 +1,3 @@ +module cmii-uav-watchdog-otp + +go 1.23 diff --git a/cmii-uav-watchdog-otp/hotp/hotp.go b/cmii-uav-watchdog-otp/hotp/hotp.go new file mode 100644 index 0000000..9f9ff19 --- /dev/null +++ b/cmii-uav-watchdog-otp/hotp/hotp.go @@ -0,0 +1,238 @@ +/** + * Copyright 2014 Paul Querna + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package hotp + +import ( + otp "cmii-uav-watchdog-otp" + "cmii-uav-watchdog-otp/internal" + + "io" + + "crypto/hmac" + "crypto/rand" + "crypto/subtle" + "encoding/base32" + "encoding/binary" + "fmt" + "math" + "net/url" + "strings" +) + +const debug = true + +// Validate a HOTP passcode given a counter and secret. +// This is a shortcut for ValidateCustom, with parameters that +// are compataible with Google-Authenticator. +func Validate(passcode string, counter uint64, secret string) bool { + rv, _ := ValidateCustom( + passcode, + counter, + secret, + ValidateOpts{ + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }, + ) + return rv +} + +// ValidateOpts provides options for ValidateCustom(). +type ValidateOpts struct { + // Digits as part of the input. Defaults to 6. + Digits otp.Digits + // Algorithm to use for HMAC. Defaults to SHA1. + Algorithm otp.Algorithm + // Encoder to use for output code. + Encoder otp.Encoder +} + +// GenerateCode creates a HOTP passcode given a counter and secret. +// This is a shortcut for GenerateCodeCustom, with parameters that +// are compataible with Google-Authenticator. +func GenerateCode(secret string, counter uint64) (string, error) { + return GenerateCodeCustom(secret, counter, ValidateOpts{ + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }) +} + +// GenerateCodeCustom uses a counter and secret value and options struct to +// create a passcode. +func GenerateCodeCustom(secret string, counter uint64, opts ValidateOpts) (passcode string, err error) { + //Set default value + if opts.Digits == 0 { + opts.Digits = otp.DigitsSix + } + // As noted in issue #10 and #17 this adds support for TOTP secrets that are + // missing their padding. + secret = strings.TrimSpace(secret) + if n := len(secret) % 8; n != 0 { + secret = secret + strings.Repeat("=", 8-n) + } + + // As noted in issue #24 Google has started producing base32 in lower case, + // but the StdEncoding (and the RFC), expect a dictionary of only upper case letters. + secret = strings.ToUpper(secret) + + secretBytes, err := base32.StdEncoding.DecodeString(secret) + if err != nil { + return "", otp.ErrValidateSecretInvalidBase32 + } + + buf := make([]byte, 8) + mac := hmac.New(opts.Algorithm.Hash, secretBytes) + binary.BigEndian.PutUint64(buf, counter) + if debug { + fmt.Printf("counter=%v\n", counter) + fmt.Printf("buf=%v\n", buf) + } + + mac.Write(buf) + sum := mac.Sum(nil) + + // "Dynamic truncation" in RFC 4226 + // http://tools.ietf.org/html/rfc4226#section-5.4 + offset := sum[len(sum)-1] & 0xf + value := int64(((int(sum[offset]) & 0x7f) << 24) | + ((int(sum[offset+1] & 0xff)) << 16) | + ((int(sum[offset+2] & 0xff)) << 8) | + (int(sum[offset+3]) & 0xff)) + + l := opts.Digits.Length() + switch opts.Encoder { + case otp.EncoderDefault: + mod := int32(value % int64(math.Pow10(l))) + + if debug { + fmt.Printf("offset=%v\n", offset) + fmt.Printf("value=%v\n", value) + fmt.Printf("mod'ed=%v\n", mod) + } + passcode = opts.Digits.Format(mod) + case otp.EncoderSteam: + // Define the character set used by Steam Guard codes. + alphabet := []byte{ + '2', '3', '4', '5', '6', '7', '8', '9', 'B', 'C', + 'D', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q', + 'R', 'T', 'V', 'W', 'X', 'Y', + } + radix := int64(len(alphabet)) + + for i := 0; i < l; i++ { + digit := value % radix + value /= radix + c := alphabet[digit] + passcode += string(c) + } + } + + return +} + +// ValidateCustom validates an HOTP with customizable options. Most users should +// use Validate(). +func ValidateCustom(passcode string, counter uint64, secret string, opts ValidateOpts) (bool, error) { + passcode = strings.TrimSpace(passcode) + + if len(passcode) != opts.Digits.Length() { + return false, otp.ErrValidateInputInvalidLength + } + + otpstr, err := GenerateCodeCustom(secret, counter, opts) + if err != nil { + return false, err + } + + if subtle.ConstantTimeCompare([]byte(otpstr), []byte(passcode)) == 1 { + return true, nil + } + + return false, nil +} + +// GenerateOpts provides options for .Generate() +type GenerateOpts struct { + // Name of the issuing Organization/Company. + Issuer string + // Name of the User's Account (eg, email address) + AccountName string + // Size in size of the generated Secret. Defaults to 10 bytes. + SecretSize uint + // Secret to store. Defaults to a randomly generated secret of SecretSize. You should generally leave this empty. + Secret []byte + // Digits to request. Defaults to 6. + Digits otp.Digits + // Algorithm to use for HMAC. Defaults to SHA1. + Algorithm otp.Algorithm + // Reader to use for generating HOTP Key. + Rand io.Reader +} + +var b32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding) + +// Generate creates a new HOTP Key. +func Generate(opts GenerateOpts) (*otp.Key, error) { + // url encode the Issuer/AccountName + if opts.Issuer == "" { + return nil, otp.ErrGenerateMissingIssuer + } + + if opts.AccountName == "" { + return nil, otp.ErrGenerateMissingAccountName + } + + if opts.SecretSize == 0 { + opts.SecretSize = 10 + } + + if opts.Digits == 0 { + opts.Digits = otp.DigitsSix + } + + if opts.Rand == nil { + opts.Rand = rand.Reader + } + + // otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example + + v := url.Values{} + if len(opts.Secret) != 0 { + v.Set("secret", b32NoPadding.EncodeToString(opts.Secret)) + } else { + secret := make([]byte, opts.SecretSize) + _, err := io.ReadFull(opts.Rand, secret) + if err != nil { + return nil, err + } + v.Set("secret", b32NoPadding.EncodeToString(secret)) + } + + v.Set("issuer", opts.Issuer) + v.Set("algorithm", opts.Algorithm.String()) + v.Set("digits", opts.Digits.String()) + + u := url.URL{ + Scheme: "otpauth", + Host: "hotp", + Path: "/" + opts.Issuer + ":" + opts.AccountName, + RawQuery: internal.EncodeQuery(v), + } + + return otp.NewKeyFromURL(u.String()) +} diff --git a/cmii-uav-watchdog-otp/internal/encode.go b/cmii-uav-watchdog-otp/internal/encode.go new file mode 100644 index 0000000..2af3c8b --- /dev/null +++ b/cmii-uav-watchdog-otp/internal/encode.go @@ -0,0 +1,35 @@ +package internal + +import ( + "net/url" + "sort" + "strings" +) + +// EncodeQuery is a copy-paste of url.Values.Encode, except it uses %20 instead +// of + to encode spaces. This is necessary to correctly render spaces in some +// authenticator apps, like Google Authenticator. +func EncodeQuery(v url.Values) string { + if v == nil { + return "" + } + var buf strings.Builder + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + vs := v[k] + keyEscaped := url.PathEscape(k) // changed from url.QueryEscape + for _, v := range vs { + if buf.Len() > 0 { + buf.WriteByte('&') + } + buf.WriteString(keyEscaped) + buf.WriteByte('=') + buf.WriteString(url.PathEscape(v)) // changed from url.QueryEscape + } + } + return buf.String() +} diff --git a/cmii-uav-watchdog-otp/otp.go b/cmii-uav-watchdog-otp/otp.go new file mode 100644 index 0000000..57253da --- /dev/null +++ b/cmii-uav-watchdog-otp/otp.go @@ -0,0 +1,248 @@ +/** + * Copyright 2014 Paul Querna + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package cmii_uav_watchdog_otp + +import ( + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "errors" + "fmt" + "hash" + "net/url" + "strconv" + "strings" +) + +// Error when attempting to convert the secret from base32 to raw bytes. +var ErrValidateSecretInvalidBase32 = errors.New("Decoding of secret as base32 failed.") + +// The user provided passcode length was not expected. +var ErrValidateInputInvalidLength = errors.New("Input length unexpected") + +// When generating a Key, the Issuer must be set. +var ErrGenerateMissingIssuer = errors.New("Issuer must be set") + +// When generating a Key, the Account Name must be set. +var ErrGenerateMissingAccountName = errors.New("AccountName must be set") + +// Key represents an TOTP or HTOP key. +type Key struct { + orig string + url *url.URL +} + +// NewKeyFromURL creates a new Key from an TOTP or HOTP url. +// +// The URL format is documented here: +// +// https://github.com/google/google-authenticator/wiki/Key-Uri-Format +func NewKeyFromURL(orig string) (*Key, error) { + s := strings.TrimSpace(orig) + + u, err := url.Parse(s) + if err != nil { + return nil, err + } + + return &Key{ + orig: s, + url: u, + }, nil +} + +func (k *Key) String() string { + return k.orig +} + +// Type returns "hotp" or "totp". +func (k *Key) Type() string { + return k.url.Host +} + +// Issuer returns the name of the issuing organization. +func (k *Key) Issuer() string { + q := k.url.Query() + + issuer := q.Get("issuer") + + if issuer != "" { + return issuer + } + + p := strings.TrimPrefix(k.url.Path, "/") + i := strings.Index(p, ":") + + if i == -1 { + return "" + } + + return p[:i] +} + +// AccountName returns the name of the user's account. +func (k *Key) AccountName() string { + p := strings.TrimPrefix(k.url.Path, "/") + i := strings.Index(p, ":") + + if i == -1 { + return p + } + + return p[i+1:] +} + +// Secret returns the opaque secret for this Key. +func (k *Key) Secret() string { + q := k.url.Query() + + return q.Get("secret") +} + +// Period returns a tiny int representing the rotation time in seconds. +func (k *Key) Period() uint64 { + q := k.url.Query() + + if u, err := strconv.ParseUint(q.Get("period"), 10, 64); err == nil { + return u + } + + // If no period is defined 30 seconds is the default per (rfc6238) + return 30 +} + +// Digits returns a tiny int representing the number of OTP digits. +func (k *Key) Digits() Digits { + q := k.url.Query() + + if u, err := strconv.ParseUint(q.Get("digits"), 10, 64); err == nil { + return Digits(u) + } + + // Six is the most common value. + return DigitsSix +} + +// Algorithm returns the algorithm used or the default (SHA1). +func (k *Key) Algorithm() Algorithm { + q := k.url.Query() + + a := strings.ToLower(q.Get("algorithm")) + switch a { + case "md5": + return AlgorithmMD5 + case "sha256": + return AlgorithmSHA256 + case "sha512": + return AlgorithmSHA512 + default: + return AlgorithmSHA1 + } +} + +// Encoder returns the encoder used or the default ("") +func (k *Key) Encoder() Encoder { + q := k.url.Query() + + a := strings.ToLower(q.Get("encoder")) + switch a { + case "steam": + return EncoderSteam + default: + return EncoderDefault + } +} + +// URL returns the OTP URL as a string +func (k *Key) URL() string { + return k.url.String() +} + +// Algorithm represents the hashing function to use in the HMAC +// operation needed for OTPs. +type Algorithm int + +const ( + // AlgorithmSHA1 should be used for compatibility with Google Authenticator. + // + // See https://github.com/pquerna/otp/issues/55 for additional details. + AlgorithmSHA1 Algorithm = iota + AlgorithmSHA256 + AlgorithmSHA512 + AlgorithmMD5 +) + +func (a Algorithm) String() string { + switch a { + case AlgorithmSHA1: + return "SHA1" + case AlgorithmSHA256: + return "SHA256" + case AlgorithmSHA512: + return "SHA512" + case AlgorithmMD5: + return "MD5" + } + panic("unreached") +} + +func (a Algorithm) Hash() hash.Hash { + switch a { + case AlgorithmSHA1: + return sha1.New() + case AlgorithmSHA256: + return sha256.New() + case AlgorithmSHA512: + return sha512.New() + case AlgorithmMD5: + return md5.New() + } + panic("unreached") +} + +// Digits represents the number of digits present in the +// user's OTP passcode. Six and Eight are the most common values. +type Digits int + +const ( + DigitsSix Digits = 6 + DigitsEight Digits = 8 +) + +// Format converts an integer into the zero-filled size for this Digits. +func (d Digits) Format(in int32) string { + f := fmt.Sprintf("%%0%dd", d) + return fmt.Sprintf(f, in) +} + +// Length returns the number of characters for this Digits. +func (d Digits) Length() int { + return int(d) +} + +func (d Digits) String() string { + return fmt.Sprintf("%d", d) +} + +type Encoder string + +const ( + EncoderDefault Encoder = "" + EncoderSteam Encoder = "steam" +) diff --git a/cmii-uav-watchdog-otp/totp/totp.go b/cmii-uav-watchdog-otp/totp/totp.go new file mode 100644 index 0000000..c234dcb --- /dev/null +++ b/cmii-uav-watchdog-otp/totp/totp.go @@ -0,0 +1,210 @@ +/** + * Copyright 2014 Paul Querna + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package totp + +import ( + otp "cmii-uav-watchdog-otp" + "cmii-uav-watchdog-otp/hotp" + "cmii-uav-watchdog-otp/internal" + "io" + + "crypto/rand" + "encoding/base32" + "math" + "net/url" + "strconv" + "time" +) + +// Validate a TOTP using the current time. +// A shortcut for ValidateCustom, Validate uses a configuration +// that is compatible with Google-Authenticator and most clients. +func Validate(passcode string, secret string) bool { + rv, _ := ValidateCustom( + passcode, + secret, + time.Now().UTC(), + ValidateOpts{ + Period: 30, + Skew: 1, + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }, + ) + return rv +} + +// GenerateCode creates a TOTP token using the current time. +// A shortcut for GenerateCodeCustom, GenerateCode uses a configuration +// that is compatible with Google-Authenticator and most clients. +func GenerateCode(secret string, t time.Time) (string, error) { + return GenerateCodeCustom(secret, t, ValidateOpts{ + Period: 30, + Skew: 1, + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }) +} + +// ValidateOpts provides options for ValidateCustom(). +type ValidateOpts struct { + // Number of seconds a TOTP hash is valid for. Defaults to 30 seconds. + Period uint + // Periods before or after the current time to allow. Value of 1 allows up to Period + // of either side of the specified time. Defaults to 0 allowed skews. Values greater + // than 1 are likely sketchy. + Skew uint + // Digits as part of the input. Defaults to 6. + Digits otp.Digits + // Algorithm to use for HMAC. Defaults to SHA1. + Algorithm otp.Algorithm + // Encoder to use for output code. + Encoder otp.Encoder +} + +// GenerateCodeCustom takes a timepoint and produces a passcode using a +// secret and the provided opts. (Under the hood, this is making an adapted +// call to hcmii-uav-watchdog-otp.GenerateCodeCustom) +func GenerateCodeCustom(secret string, t time.Time, opts ValidateOpts) (passcode string, err error) { + if opts.Period == 0 { + opts.Period = 30 + } + counter := uint64(math.Floor(float64(t.Unix()) / float64(opts.Period))) + passcode, err = hotp.GenerateCodeCustom(secret, counter, hotp.ValidateOpts{ + Digits: opts.Digits, + Algorithm: opts.Algorithm, + Encoder: opts.Encoder, + }) + if err != nil { + return "", err + } + return passcode, nil +} + +// ValidateCustom validates a TOTP given a user specified time and custom options. +// Most users should use Validate() to provide an interpolatable TOTP experience. +func ValidateCustom(passcode string, secret string, t time.Time, opts ValidateOpts) (bool, error) { + if opts.Period == 0 { + opts.Period = 30 + } + + counters := []uint64{} + counter := int64(math.Floor(float64(t.Unix()) / float64(opts.Period))) + + counters = append(counters, uint64(counter)) + for i := 1; i <= int(opts.Skew); i++ { + counters = append(counters, uint64(counter+int64(i))) + counters = append(counters, uint64(counter-int64(i))) + } + + for _, counter := range counters { + rv, err := hotp.ValidateCustom(passcode, counter, secret, hotp.ValidateOpts{ + Digits: opts.Digits, + Algorithm: opts.Algorithm, + Encoder: opts.Encoder, + }) + if err != nil { + return false, err + } + + if rv == true { + return true, nil + } + } + + return false, nil +} + +// GenerateOpts provides options for Generate(). The default values +// are compatible with Google-Authenticator. +type GenerateOpts struct { + // Name of the issuing Organization/Company. + Issuer string + // Name of the User's Account (eg, email address) + AccountName string + // Number of seconds a TOTP hash is valid for. Defaults to 30 seconds. + Period uint + // Size in size of the generated Secret. Defaults to 20 bytes. + SecretSize uint + // Secret to store. Defaults to a randomly generated secret of SecretSize. You should generally leave this empty. + Secret []byte + // Digits to request. Defaults to 6. + Digits otp.Digits + // Algorithm to use for HMAC. Defaults to SHA1. + Algorithm otp.Algorithm + // Reader to use for generating TOTP Key. + Rand io.Reader +} + +var b32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding) + +// Generate a new TOTP Key. +func Generate(opts GenerateOpts) (*otp.Key, error) { + // url encode the Issuer/AccountName + if opts.Issuer == "" { + return nil, otp.ErrGenerateMissingIssuer + } + + if opts.AccountName == "" { + return nil, otp.ErrGenerateMissingAccountName + } + + if opts.Period == 0 { + opts.Period = 30 + } + + if opts.SecretSize == 0 { + opts.SecretSize = 20 + } + + if opts.Digits == 0 { + opts.Digits = otp.DigitsSix + } + + if opts.Rand == nil { + opts.Rand = rand.Reader + } + + // otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example + + v := url.Values{} + if len(opts.Secret) != 0 { + v.Set("secret", b32NoPadding.EncodeToString(opts.Secret)) + } else { + secret := make([]byte, opts.SecretSize) + _, err := io.ReadFull(opts.Rand, secret) + if err != nil { + return nil, err + } + v.Set("secret", b32NoPadding.EncodeToString(secret)) + } + + v.Set("issuer", opts.Issuer) + v.Set("period", strconv.FormatUint(uint64(opts.Period), 10)) + v.Set("algorithm", opts.Algorithm.String()) + v.Set("digits", opts.Digits.String()) + + u := url.URL{ + Scheme: "otpauth", + Host: "totp", + Path: "/" + opts.Issuer + ":" + opts.AccountName, + RawQuery: internal.EncodeQuery(v), + } + + return otp.NewKeyFromURL(u.String()) +}