Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2a3081ce5 | |||
| 3149d958f4 | |||
| f296344acf | |||
| 3fb2d88a3f | |||
| 150c02b377 | |||
| 83f88c49fe | |||
| 7c437ac45f | |||
| c999bf35b0 | |||
| 4dc135cfe0 | |||
| 790113e189 | |||
| 0dcd18c6f1 | |||
| 024d552293 | |||
| 9cd2ced695 | |||
| 619c08a13f | |||
| 944a57bf0e | |||
| 0857b29624 | |||
|
|
e95404bfc5 | ||
|
|
924654e7c4 | ||
| 9e0979e07f | |||
|
|
bbc82ff8de | ||
|
|
5fd928f69a | ||
|
|
acefe4a3b9 | ||
| a1452cebc9 | |||
| 6e9812e6f5 | |||
|
|
8c34415c34 | ||
|
|
2cf2c15def | ||
|
|
eaad1884d4 | ||
| 5d57d844d4 | |||
|
|
31b9d175dd | ||
|
|
79e106da2e | ||
|
|
939b1bc272 | ||
|
|
89e74f390b | ||
|
|
7881b6fdfc | ||
|
|
5bef33245f | ||
|
|
84250b0501 | ||
|
|
459e9f880f | ||
|
|
0982f47ce3 | ||
|
|
1dec15fd11 | ||
|
|
2ee9cae5ba | ||
|
|
dc04475120 | ||
|
|
dbbd5116b5 |
@@ -12,6 +12,12 @@
|
||||
|
||||
version: "2"
|
||||
|
||||
output:
|
||||
sort-order:
|
||||
- file
|
||||
- linter
|
||||
- severity
|
||||
|
||||
issues:
|
||||
# Maximum count of issues with the same text.
|
||||
# Set to 0 to disable.
|
||||
@@ -454,6 +460,8 @@ linters:
|
||||
- -QF1008
|
||||
# We often explicitly enable old/deprecated ciphers for research.
|
||||
- -SA1019
|
||||
# Covered by revive.
|
||||
- -ST1003
|
||||
|
||||
usetesting:
|
||||
# Enable/disable `os.TempDir()` detections.
|
||||
@@ -472,6 +480,8 @@ linters:
|
||||
rules:
|
||||
- path: 'ahash/ahash.go'
|
||||
linters: [ staticcheck, gosec ]
|
||||
- path: 'twofactor/.*.go'
|
||||
linters: [ exhaustive, mnd, revive ]
|
||||
- path: 'backoff/backoff_test.go'
|
||||
linters: [ testpackage ]
|
||||
- path: 'dbg/dbg_test.go'
|
||||
|
||||
42
CHANGELOG
42
CHANGELOG
@@ -1,5 +1,47 @@
|
||||
CHANGELOG
|
||||
|
||||
v1.13.0 - 2025-11-16
|
||||
|
||||
Add:
|
||||
- cmd/certser: print serial numbers for certificates.
|
||||
- lib/HexEncode: add a new hex encode function handling multiple output
|
||||
formats, including with and without colons.
|
||||
|
||||
v1.12.4 - 2025-11-16
|
||||
|
||||
Changed:
|
||||
|
||||
- Linting fixes for twofactor that were previously masked.
|
||||
|
||||
v1.12.3 erroneously tagged and pushed
|
||||
|
||||
v1.12.2 - 2025-11-16
|
||||
|
||||
Changed:
|
||||
|
||||
- add rsc.io/qr dependency for twofactor.
|
||||
|
||||
v1.12.1 - 2025-11-16
|
||||
|
||||
Changed:
|
||||
- twofactor: Remove go.{mod,sum}.
|
||||
|
||||
v1.12.0 - 2025-11-16
|
||||
|
||||
Added
|
||||
- twofactor: the github.com/kisom/twofactor repo has been subtree'd
|
||||
into this repo.
|
||||
|
||||
v1.11.2 - 2025-11-16
|
||||
|
||||
Changed
|
||||
- cmd/ski, cmd/csrpubdump, cmd/tlskeypair: centralize
|
||||
certificate/private-key/CSR parsing by reusing certlib helpers.
|
||||
This reduces duplication and improves consistency across commands.
|
||||
- csr: CSR parsing in the above commands now uses certlib.ParseCSR,
|
||||
which verifies CSR signatures (behavioral hardening compared to
|
||||
prior parsing without signature verification).
|
||||
|
||||
v1.11.1 - 2025-11-16
|
||||
|
||||
Changed
|
||||
|
||||
53
README.md
53
README.md
@@ -2,39 +2,52 @@ GOUTILS
|
||||
|
||||
This is a collection of small utility code I've written in Go; the `cmd/`
|
||||
directory has a number of command-line utilities. Rather than keep all
|
||||
of these in superfluous repositories of their own, or rewriting them
|
||||
of these in superfluous repositories of their own or rewriting them
|
||||
for each project, I'm putting them here.
|
||||
|
||||
The project can be built with the standard Go tooling, or it can be built
|
||||
with Bazel.
|
||||
The project can be built with the standard Go tooling.
|
||||
|
||||
Contents:
|
||||
|
||||
ahash/ Provides hashes from string algorithm specifiers.
|
||||
assert/ Error handling, assertion-style.
|
||||
backoff/ Implementation of an intelligent backoff strategy.
|
||||
cache/ Implementations of various caches.
|
||||
lru/ Least-recently-used cache.
|
||||
mru/ Most-recently-used cache.
|
||||
certlib/ Library for working with TLS certificates.
|
||||
cmd/
|
||||
atping/ Automated TCP ping, meant for putting in cronjobs.
|
||||
certchain/ Display the certificate chain from a
|
||||
TLS connection.
|
||||
ca-signed/ Validate whether a certificate is signed by a CA.
|
||||
cert-bundler/
|
||||
Create certificate bundles from a source of PEM
|
||||
certificates.
|
||||
cert-revcheck/
|
||||
Check whether a certificate has been revoked or is
|
||||
expired.
|
||||
certchain/ Display the certificate chain from a TLS connection.
|
||||
certdump/ Dump certificate information.
|
||||
certexpiry/ Print a list of certificate subjects and expiry times
|
||||
or warn about certificates expiring within a certain
|
||||
window.
|
||||
certverify/ Verify a TLS X.509 certificate, optionally printing
|
||||
certverify/ Verify a TLS X.509 certificate file, optionally printing
|
||||
the time to expiry and checking for revocations.
|
||||
clustersh/ Run commands or transfer files across multiple
|
||||
servers via SSH.
|
||||
cruntar/ Untar an archive with hard links, copying instead of
|
||||
cruntar/ (Un)tar an archive with hard links, copying instead of
|
||||
linking.
|
||||
csrpubdump/ Dump the public key from an X.509 certificate request.
|
||||
data_sync/ Sync the user's homedir to external storage.
|
||||
diskimg/ Write a disk image to a device.
|
||||
dumpbytes/ Dump the contents of a file as hex bytes, printing it as
|
||||
a Go []byte literal.
|
||||
eig/ EEPROM image generator.
|
||||
fragment/ Print a fragment of a file.
|
||||
host/ Go imlpementation of the host(1) command.
|
||||
jlp/ JSON linter/prettifier.
|
||||
kgz/ Custom gzip compressor / decompressor that handles 99%
|
||||
of my use cases.
|
||||
minmax/ Generate a minmax code for use in uLisp.
|
||||
parts/ Simple parts database management for my collection of
|
||||
electronic components.
|
||||
pem2bin/ Dump the binary body of a PEM-encoded block.
|
||||
@@ -44,41 +57,45 @@ Contents:
|
||||
in a bundle.
|
||||
renfnv/ Rename a file to base32-encoded 64-bit FNV-1a hash.
|
||||
rhash/ Compute the digest of remote files.
|
||||
rolldie/ Roll some dice.
|
||||
showimp/ List the external (e.g. non-stdlib and outside the
|
||||
current working directory) imports for a Go file.
|
||||
ski Display the SKI for PEM-encoded TLS material.
|
||||
sprox/ Simple TCP proxy.
|
||||
stealchain/ Dump the verified chain from a TLS
|
||||
connection to a server.
|
||||
stealchain- Dump the verified chain from a TLS
|
||||
server/ connection from a client.
|
||||
stealchain/ Dump the verified chain from a TLS connection to a
|
||||
server.
|
||||
stealchain-server/
|
||||
Dump the verified chain from a TLS connection from
|
||||
from a client.
|
||||
subjhash/ Print or match subject info from a certificate.
|
||||
tlsinfo/ Print information about a TLS connection (the TLS version
|
||||
and cipher suite).
|
||||
tlskeypair/ Check whether a TLS certificate and key file match.
|
||||
utc/ Convert times to UTC.
|
||||
yamll/ A small YAML linter.
|
||||
zsearch/ Search for a string in directory of gzipped files.
|
||||
config/ A simple global configuration system where configuration
|
||||
data is pulled from a file or an environment variable
|
||||
transparently.
|
||||
iniconf/ A simple INI-style configuration system.
|
||||
dbg/ A debug printer.
|
||||
die/ Death of a program.
|
||||
fileutil/ Common file functions.
|
||||
lib/ Commonly-useful functions for writing Go programs.
|
||||
log/ A syslog library.
|
||||
logging/ A logging library.
|
||||
mwc/ MultiwriteCloser implementation.
|
||||
rand/ Utilities for working with math/rand.
|
||||
sbuf/ A byte buffer that can be wiped.
|
||||
seekbuf/ A read-seekable byte buffer.
|
||||
syslog/ Syslog-type logging.
|
||||
tee/ Emulate tee(1)'s functionality in io.Writers.
|
||||
testio/ Various I/O utilities useful during testing.
|
||||
testutil/ Various utility functions useful during testing.
|
||||
|
||||
twofactor/ Two-factor authentication.
|
||||
|
||||
Each program should have a small README in the directory with more
|
||||
information.
|
||||
|
||||
All code here is licensed under the ISC license.
|
||||
|
||||
All code here is licensed under the Apache 2.0 license.
|
||||
|
||||
Error handling
|
||||
--------------
|
||||
@@ -99,7 +116,7 @@ Examples:
|
||||
```
|
||||
cert, err := certlib.LoadCertificate(path)
|
||||
if err != nil {
|
||||
// sentinel match
|
||||
// sentinel match:
|
||||
if errors.Is(err, certerr.ErrEmptyCertificate) {
|
||||
// handle empty input
|
||||
}
|
||||
@@ -116,5 +133,3 @@ if err != nil {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Avoid including sensitive data (keys, passwords, tokens) in error messages.
|
||||
|
||||
51
cmd/certser/main.go
Normal file
51
cmd/certser/main.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/certlib"
|
||||
"git.wntrmute.dev/kyle/goutils/die"
|
||||
"git.wntrmute.dev/kyle/goutils/lib"
|
||||
)
|
||||
|
||||
const displayInt lib.HexEncodeMode = iota
|
||||
|
||||
func parseDisplayMode(mode string) lib.HexEncodeMode {
|
||||
mode = strings.ToLower(mode)
|
||||
|
||||
if mode == "int" {
|
||||
return displayInt
|
||||
}
|
||||
|
||||
return lib.ParseHexEncodeMode(mode)
|
||||
}
|
||||
|
||||
func serialString(cert *x509.Certificate, mode lib.HexEncodeMode) string {
|
||||
if mode == displayInt {
|
||||
return cert.SerialNumber.String()
|
||||
}
|
||||
|
||||
return lib.HexEncode(cert.SerialNumber.Bytes(), mode)
|
||||
}
|
||||
|
||||
func main() {
|
||||
displayAs := flag.String("d", "int", "display mode (int, hex, uhex)")
|
||||
showExpiry := flag.Bool("e", false, "show expiry date")
|
||||
flag.Parse()
|
||||
|
||||
displayMode := parseDisplayMode(*displayAs)
|
||||
|
||||
for _, arg := range flag.Args() {
|
||||
cert, err := certlib.LoadCertificate(arg)
|
||||
die.If(err)
|
||||
|
||||
fmt.Printf("%s: %s", arg, serialString(cert, displayMode))
|
||||
if *showExpiry {
|
||||
fmt.Printf(" (%s)", cert.NotAfter.Format("2006-01-02"))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/certlib"
|
||||
"git.wntrmute.dev/kyle/goutils/die"
|
||||
)
|
||||
|
||||
@@ -19,14 +20,7 @@ func main() {
|
||||
in, err := os.ReadFile(fileName)
|
||||
die.If(err)
|
||||
|
||||
if p, _ := pem.Decode(in); p != nil {
|
||||
if p.Type != "CERTIFICATE REQUEST" {
|
||||
die.With("INVALID FILE TYPE")
|
||||
}
|
||||
in = p.Bytes
|
||||
}
|
||||
|
||||
csr, err := x509.ParseCertificateRequest(in)
|
||||
csr, _, err := certlib.ParseCSR(in)
|
||||
die.If(err)
|
||||
|
||||
out, err := x509.MarshalPKIXPublicKey(csr.PublicKey)
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1" // #nosec G505
|
||||
@@ -16,6 +15,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/certlib"
|
||||
"git.wntrmute.dev/kyle/goutils/die"
|
||||
"git.wntrmute.dev/kyle/goutils/lib"
|
||||
)
|
||||
@@ -83,28 +83,19 @@ func parse(path string) ([]byte, string, string) {
|
||||
}
|
||||
|
||||
func parseKey(data []byte) ([]byte, string) {
|
||||
privInterface, err := x509.ParsePKCS8PrivateKey(data)
|
||||
priv, err := certlib.ParsePrivateKeyDER(data)
|
||||
if err != nil {
|
||||
privInterface, err = x509.ParsePKCS1PrivateKey(data)
|
||||
if err != nil {
|
||||
privInterface, err = x509.ParseECPrivateKey(data)
|
||||
if err != nil {
|
||||
die.With("couldn't parse private key.")
|
||||
}
|
||||
}
|
||||
die.If(err)
|
||||
}
|
||||
|
||||
var priv crypto.Signer
|
||||
var kt string
|
||||
switch p := privInterface.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
priv = p
|
||||
switch priv.Public().(type) {
|
||||
case *rsa.PublicKey:
|
||||
kt = keyTypeRSA
|
||||
case *ecdsa.PrivateKey:
|
||||
priv = p
|
||||
case *ecdsa.PublicKey:
|
||||
kt = keyTypeECDSA
|
||||
default:
|
||||
die.With("unknown private key type %T", privInterface)
|
||||
die.With("unknown private key type %T", priv)
|
||||
}
|
||||
|
||||
public, err := x509.MarshalPKIXPublicKey(priv.Public())
|
||||
@@ -134,7 +125,8 @@ func parseCertificate(data []byte) ([]byte, string) {
|
||||
}
|
||||
|
||||
func parseCSR(data []byte) ([]byte, string) {
|
||||
csr, err := x509.ParseCertificateRequest(data)
|
||||
// Use certlib to support both PEM and DER and to centralize validation.
|
||||
csr, _, err := certlib.ParseCSR(data)
|
||||
die.If(err)
|
||||
|
||||
pub := csr.PublicKey
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/certlib"
|
||||
"git.wntrmute.dev/kyle/goutils/die"
|
||||
)
|
||||
|
||||
@@ -124,34 +125,14 @@ func loadKey(path string) (crypto.Signer, error) {
|
||||
}
|
||||
|
||||
in = bytes.TrimSpace(in)
|
||||
p, _ := pem.Decode(in)
|
||||
if p != nil {
|
||||
if p, _ := pem.Decode(in); p != nil {
|
||||
if !validPEMs[p.Type] {
|
||||
return nil, errors.New("invalid private key file type " + p.Type)
|
||||
}
|
||||
in = p.Bytes
|
||||
return certlib.ParsePrivateKeyPEM(in)
|
||||
}
|
||||
|
||||
priv, err := x509.ParsePKCS8PrivateKey(in)
|
||||
if err != nil {
|
||||
priv, err = x509.ParsePKCS1PrivateKey(in)
|
||||
if err != nil {
|
||||
priv, err = x509.ParseECPrivateKey(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch p := priv.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
return p, nil
|
||||
case *ecdsa.PrivateKey:
|
||||
return p, nil
|
||||
default:
|
||||
// should never reach here
|
||||
return nil, errors.New("invalid private key")
|
||||
}
|
||||
return certlib.ParsePrivateKeyDER(in)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
1
go.mod
1
go.mod
@@ -22,4 +22,5 @@ require (
|
||||
github.com/kr/pretty v0.1.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
|
||||
2
go.sum
2
go.sum
@@ -44,3 +44,5 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
110
lib/lib.go
110
lib/lib.go
@@ -2,9 +2,11 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -109,3 +111,111 @@ func Duration(d time.Duration) string {
|
||||
s += fmt.Sprintf("%dh%s", hours, d)
|
||||
return s
|
||||
}
|
||||
|
||||
type HexEncodeMode uint8
|
||||
|
||||
const (
|
||||
// HexEncodeLower prints the bytes as lowercase hexadecimal.
|
||||
HexEncodeLower HexEncodeMode = iota + 1
|
||||
// HexEncodeUpper prints the bytes as uppercase hexadecimal.
|
||||
HexEncodeUpper
|
||||
// HexEncodeLowerColon prints the bytes as lowercase hexadecimal
|
||||
// with colons between each pair of bytes.
|
||||
HexEncodeLowerColon
|
||||
// HexEncodeUpperColon prints the bytes as uppercase hexadecimal
|
||||
// with colons between each pair of bytes.
|
||||
HexEncodeUpperColon
|
||||
)
|
||||
|
||||
func (m HexEncodeMode) String() string {
|
||||
switch m {
|
||||
case HexEncodeLower:
|
||||
return "lower"
|
||||
case HexEncodeUpper:
|
||||
return "upper"
|
||||
case HexEncodeLowerColon:
|
||||
return "lcolon"
|
||||
case HexEncodeUpperColon:
|
||||
return "ucolon"
|
||||
default:
|
||||
panic("invalid hex encode mode")
|
||||
}
|
||||
}
|
||||
|
||||
func ParseHexEncodeMode(s string) HexEncodeMode {
|
||||
switch strings.ToLower(s) {
|
||||
case "lower":
|
||||
return HexEncodeLower
|
||||
case "upper":
|
||||
return HexEncodeUpper
|
||||
case "lcolon":
|
||||
return HexEncodeLowerColon
|
||||
case "ucolon":
|
||||
return HexEncodeUpperColon
|
||||
}
|
||||
|
||||
panic("invalid hex encode mode")
|
||||
}
|
||||
|
||||
func hexColons(s string) string {
|
||||
if len(s)%2 != 0 {
|
||||
fmt.Fprintf(os.Stderr, "hex string: %s\n", s)
|
||||
fmt.Fprintf(os.Stderr, "hex length: %d\n", len(s))
|
||||
panic("invalid hex string length")
|
||||
}
|
||||
|
||||
n := len(s)
|
||||
if n <= 2 {
|
||||
return s
|
||||
}
|
||||
|
||||
pairCount := n / 2
|
||||
if n%2 != 0 {
|
||||
pairCount++
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(n + pairCount - 1)
|
||||
|
||||
for i := 0; i < n; i += 2 {
|
||||
b.WriteByte(s[i])
|
||||
|
||||
if i+1 < n {
|
||||
b.WriteByte(s[i+1])
|
||||
}
|
||||
|
||||
if i+2 < n {
|
||||
b.WriteByte(':')
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func hexEncode(b []byte) string {
|
||||
s := hex.EncodeToString(b)
|
||||
|
||||
if len(s)%2 != 0 {
|
||||
s = "0" + s
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// HexEncode encodes the given bytes as a hexadecimal string.
|
||||
func HexEncode(b []byte, mode HexEncodeMode) string {
|
||||
str := hexEncode(b)
|
||||
|
||||
switch mode {
|
||||
case HexEncodeLower:
|
||||
return str
|
||||
case HexEncodeUpper:
|
||||
return strings.ToUpper(str)
|
||||
case HexEncodeLowerColon:
|
||||
return hexColons(str)
|
||||
case HexEncodeUpperColon:
|
||||
return strings.ToUpper(hexColons(str))
|
||||
default:
|
||||
panic("invalid hex encode mode")
|
||||
}
|
||||
}
|
||||
|
||||
79
lib/lib_test.go
Normal file
79
lib/lib_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package lib_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/lib"
|
||||
)
|
||||
|
||||
func TestHexEncode_LowerUpper(t *testing.T) {
|
||||
b := []byte{0x0f, 0xa1, 0x00, 0xff}
|
||||
|
||||
gotLower := lib.HexEncode(b, lib.HexEncodeLower)
|
||||
if gotLower != "0fa100ff" {
|
||||
t.Fatalf("lib.HexEncode lower: expected %q, got %q", "0fa100ff", gotLower)
|
||||
}
|
||||
|
||||
gotUpper := lib.HexEncode(b, lib.HexEncodeUpper)
|
||||
if gotUpper != "0FA100FF" {
|
||||
t.Fatalf("lib.HexEncode upper: expected %q, got %q", "0FA100FF", gotUpper)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexEncode_ColonModes(t *testing.T) {
|
||||
// Includes leading zero nibble and a zero byte to verify padding and separators
|
||||
b := []byte{0x0f, 0xa1, 0x00, 0xff}
|
||||
|
||||
gotLColon := lib.HexEncode(b, lib.HexEncodeLowerColon)
|
||||
if gotLColon != "0f:a1:00:ff" {
|
||||
t.Fatalf("lib.HexEncode colon lower: expected %q, got %q", "0f:a1:00:ff", gotLColon)
|
||||
}
|
||||
|
||||
gotUColon := lib.HexEncode(b, lib.HexEncodeUpperColon)
|
||||
if gotUColon != "0F:A1:00:FF" {
|
||||
t.Fatalf("lib.HexEncode colon upper: expected %q, got %q", "0F:A1:00:FF", gotUColon)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexEncode_EmptyInput(t *testing.T) {
|
||||
var b []byte
|
||||
if got := lib.HexEncode(b, lib.HexEncodeLower); got != "" {
|
||||
t.Fatalf("empty lower: expected empty string, got %q", got)
|
||||
}
|
||||
if got := lib.HexEncode(b, lib.HexEncodeUpper); got != "" {
|
||||
t.Fatalf("empty upper: expected empty string, got %q", got)
|
||||
}
|
||||
if got := lib.HexEncode(b, lib.HexEncodeLowerColon); got != "" {
|
||||
t.Fatalf("empty colon lower: expected empty string, got %q", got)
|
||||
}
|
||||
if got := lib.HexEncode(b, lib.HexEncodeUpperColon); got != "" {
|
||||
t.Fatalf("empty colon upper: expected empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexEncode_SingleByte(t *testing.T) {
|
||||
b := []byte{0x0f}
|
||||
if got := lib.HexEncode(b, lib.HexEncodeLower); got != "0f" {
|
||||
t.Fatalf("single byte lower: expected %q, got %q", "0f", got)
|
||||
}
|
||||
if got := lib.HexEncode(b, lib.HexEncodeUpper); got != "0F" {
|
||||
t.Fatalf("single byte upper: expected %q, got %q", "0F", got)
|
||||
}
|
||||
// For a single byte, colon modes should not introduce separators
|
||||
if got := lib.HexEncode(b, lib.HexEncodeLowerColon); got != "0f" {
|
||||
t.Fatalf("single byte colon lower: expected %q, got %q", "0f", got)
|
||||
}
|
||||
if got := lib.HexEncode(b, lib.HexEncodeUpperColon); got != "0F" {
|
||||
t.Fatalf("single byte colon upper: expected %q, got %q", "0F", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexEncode_InvalidModePanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatalf("expected panic for invalid mode, but function returned normally")
|
||||
}
|
||||
}()
|
||||
// 0 is not a valid lib.HexEncodeMode (valid modes start at 1)
|
||||
_ = lib.HexEncode([]byte{0x01}, lib.HexEncodeMode(0))
|
||||
}
|
||||
33
twofactor/README.md
Normal file
33
twofactor/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
## `twofactor`
|
||||
|
||||
[](https://godoc.org/github.com/gokyle/twofactor)
|
||||
|
||||
|
||||
### Author
|
||||
|
||||
`twofactor` was written by Kyle Isom <kyle@tyrfingr.is>.
|
||||
|
||||
|
||||
### License
|
||||
|
||||
```
|
||||
Copyright (c) 2017 Kyle Isom <kyle@imap.cc>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
5
twofactor/doc.go
Normal file
5
twofactor/doc.go
Normal file
@@ -0,0 +1,5 @@
|
||||
// Package twofactor implements two-factor authentication.
|
||||
//
|
||||
// Currently supported are RFC 4226 HOTP one-time passwords and
|
||||
// RFC 6238 TOTP SHA-1 one-time passwords.
|
||||
package twofactor
|
||||
103
twofactor/hotp.go
Normal file
103
twofactor/hotp.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/sha1" // #nosec G505 - required by RFC
|
||||
"encoding/base32"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HOTP represents an RFC-4226 Hash-based One Time Password instance.
|
||||
type HOTP struct {
|
||||
*OATH
|
||||
}
|
||||
|
||||
// NewHOTP takes the key, the initial counter value, and the number
|
||||
// of digits (typically 6 or 8) and returns a new HOTP instance.
|
||||
func NewHOTP(key []byte, counter uint64, digits int) *HOTP {
|
||||
return &HOTP{
|
||||
OATH: &OATH{
|
||||
key: key,
|
||||
counter: counter,
|
||||
size: digits,
|
||||
hash: sha1.New,
|
||||
algo: crypto.SHA1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Type returns OATH_HOTP.
|
||||
func (otp *HOTP) Type() Type {
|
||||
return OATH_HOTP
|
||||
}
|
||||
|
||||
// OTP returns the next OTP and increments the counter.
|
||||
func (otp *HOTP) OTP() string {
|
||||
code := otp.OATH.OTP(otp.counter)
|
||||
otp.counter++
|
||||
return code
|
||||
}
|
||||
|
||||
// URL returns an HOTP URL (i.e. for putting in a QR code).
|
||||
func (otp *HOTP) URL(label string) string {
|
||||
return otp.OATH.URL(otp.Type(), label)
|
||||
}
|
||||
|
||||
// SetProvider sets up the provider component of the OTP URL.
|
||||
func (otp *HOTP) SetProvider(provider string) {
|
||||
otp.provider = provider
|
||||
}
|
||||
|
||||
// GenerateGoogleHOTP generates a new HOTP instance as used by
|
||||
// Google Authenticator.
|
||||
func GenerateGoogleHOTP() *HOTP {
|
||||
key := make([]byte, sha1.Size)
|
||||
if _, err := io.ReadFull(PRNG, key); err != nil {
|
||||
return nil
|
||||
}
|
||||
return NewHOTP(key, 0, 6)
|
||||
}
|
||||
|
||||
func hotpFromURL(u *url.URL) (*HOTP, string, error) {
|
||||
label := u.Path[1:]
|
||||
v := u.Query()
|
||||
|
||||
secret := strings.ToUpper(v.Get("secret"))
|
||||
if secret == "" {
|
||||
return nil, "", ErrInvalidURL
|
||||
}
|
||||
|
||||
var digits = 6
|
||||
if sdigit := v.Get("digits"); sdigit != "" {
|
||||
tmpDigits, err := strconv.ParseInt(sdigit, 10, 8)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
digits = int(tmpDigits)
|
||||
}
|
||||
|
||||
var counter uint64
|
||||
if scounter := v.Get("counter"); scounter != "" {
|
||||
var err error
|
||||
counter, err = strconv.ParseUint(scounter, 10, 64)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
|
||||
key, err := base32.StdEncoding.DecodeString(Pad(secret))
|
||||
if err != nil {
|
||||
// assume secret isn't base32 encoded
|
||||
key = []byte(secret)
|
||||
}
|
||||
otp := NewHOTP(key, counter, digits)
|
||||
return otp, label, nil
|
||||
}
|
||||
|
||||
// QR generates a new QR code for the HOTP.
|
||||
func (otp *HOTP) QR(label string) ([]byte, error) {
|
||||
return otp.OATH.QR(otp.Type(), label)
|
||||
}
|
||||
58
twofactor/hotp_internal_test.go
Normal file
58
twofactor/hotp_internal_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
var testKey = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}
|
||||
|
||||
var rfcHotpKey = []byte("12345678901234567890")
|
||||
var rfcHotpExpected = []string{
|
||||
"755224",
|
||||
"287082",
|
||||
"359152",
|
||||
"969429",
|
||||
"338314",
|
||||
"254676",
|
||||
"287922",
|
||||
"162583",
|
||||
"399871",
|
||||
"520489",
|
||||
}
|
||||
|
||||
// This test runs through the test cases presented in the RFC, and
|
||||
// ensures that this implementation is in compliance.
|
||||
func TestHotpRFC(t *testing.T) {
|
||||
otp := NewHOTP(rfcHotpKey, 0, 6)
|
||||
for i := range rfcHotpExpected {
|
||||
if otp.Counter() != uint64(i) {
|
||||
t.Fatalf("twofactor: invalid counter (should be %d, is %d",
|
||||
i, otp.Counter())
|
||||
}
|
||||
code := otp.OTP()
|
||||
if code == "" {
|
||||
t.Fatal("twofactor: failed to produce an OTP")
|
||||
} else if code != rfcHotpExpected[i] {
|
||||
t.Logf("twofactor: invalid OTP\n")
|
||||
t.Logf("\tExpected: %s\n", rfcHotpExpected[i])
|
||||
t.Logf("\t Actual: %s\n", code)
|
||||
t.Fatalf("\t Counter: %d\n", otp.counter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This test uses a different key than the test cases in the RFC,
|
||||
// but runs through the same test cases to ensure that they fail as
|
||||
// expected.
|
||||
func TestHotpBadRFC(t *testing.T) {
|
||||
otp := NewHOTP(testKey, 0, 6)
|
||||
for i := range rfcHotpExpected {
|
||||
code := otp.OTP()
|
||||
switch code {
|
||||
case "":
|
||||
t.Error("twofactor: failed to produce an OTP")
|
||||
case rfcHotpExpected[i]:
|
||||
t.Error("twofactor: should not have received a valid OTP")
|
||||
}
|
||||
}
|
||||
}
|
||||
150
twofactor/oath.go
Normal file
150
twofactor/oath.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/hmac"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"rsc.io/qr"
|
||||
)
|
||||
|
||||
const defaultSize = 6
|
||||
|
||||
// OATH provides a baseline structure for the two OATH algorithms.
|
||||
type OATH struct {
|
||||
key []byte
|
||||
counter uint64
|
||||
size int
|
||||
hash func() hash.Hash
|
||||
algo crypto.Hash
|
||||
provider string
|
||||
}
|
||||
|
||||
// Size returns the output size (in characters) of the password.
|
||||
func (o *OATH) Size() int {
|
||||
return o.size
|
||||
}
|
||||
|
||||
// Counter returns the OATH token's counter.
|
||||
func (o *OATH) Counter() uint64 {
|
||||
return o.counter
|
||||
}
|
||||
|
||||
// SetCounter updates the OATH token's counter to a new value.
|
||||
func (o *OATH) SetCounter(counter uint64) {
|
||||
o.counter = counter
|
||||
}
|
||||
|
||||
// Key returns the token's secret key.
|
||||
func (o *OATH) Key() []byte {
|
||||
return o.key
|
||||
}
|
||||
|
||||
// Hash returns the token's hash function.
|
||||
func (o *OATH) Hash() func() hash.Hash {
|
||||
return o.hash
|
||||
}
|
||||
|
||||
// URL constructs a URL appropriate for the token (i.e. for use in a
|
||||
// QR code).
|
||||
func (o *OATH) URL(t Type, label string) string {
|
||||
secret := base32.StdEncoding.EncodeToString(o.key)
|
||||
u := url.URL{}
|
||||
v := url.Values{}
|
||||
u.Scheme = "otpauth"
|
||||
switch t {
|
||||
case OATH_HOTP:
|
||||
u.Host = "hotp"
|
||||
case OATH_TOTP:
|
||||
u.Host = "totp"
|
||||
}
|
||||
u.Path = label
|
||||
v.Add("secret", secret)
|
||||
if o.Counter() != 0 && t == OATH_HOTP {
|
||||
v.Add("counter", strconv.FormatUint(o.Counter(), 10))
|
||||
}
|
||||
if o.Size() != defaultSize {
|
||||
v.Add("digits", strconv.Itoa(o.Size()))
|
||||
}
|
||||
|
||||
switch o.algo {
|
||||
case crypto.SHA256:
|
||||
v.Add("algorithm", "SHA256")
|
||||
case crypto.SHA512:
|
||||
v.Add("algorithm", "SHA512")
|
||||
}
|
||||
|
||||
if o.provider != "" {
|
||||
v.Add("provider", o.provider)
|
||||
}
|
||||
|
||||
u.RawQuery = v.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
var digits = []int64{
|
||||
0: 1,
|
||||
1: 10,
|
||||
2: 100,
|
||||
3: 1000,
|
||||
4: 10000,
|
||||
5: 100000,
|
||||
6: 1000000,
|
||||
7: 10000000,
|
||||
8: 100000000,
|
||||
9: 1000000000,
|
||||
10: 10000000000,
|
||||
}
|
||||
|
||||
// OTP top-level type should provide a counter; for example, HOTP
|
||||
// will provide the counter directly while TOTP will provide the
|
||||
// time-stepped counter.
|
||||
func (o *OATH) OTP(counter uint64) string {
|
||||
var ctr [8]byte
|
||||
binary.BigEndian.PutUint64(ctr[:], counter)
|
||||
|
||||
var mod int64 = 1
|
||||
if len(digits) > o.size {
|
||||
for i := 1; i <= o.size; i++ {
|
||||
mod *= 10
|
||||
}
|
||||
} else {
|
||||
mod = digits[o.size]
|
||||
}
|
||||
|
||||
h := hmac.New(o.hash, o.key)
|
||||
h.Write(ctr[:])
|
||||
dt := truncate(h.Sum(nil)) % mod
|
||||
fmtStr := fmt.Sprintf("%%0%dd", o.size)
|
||||
return fmt.Sprintf(fmtStr, dt)
|
||||
}
|
||||
|
||||
// truncate contains the DT function from the RFC; this is used to
|
||||
// deterministically select a sequence of 4 bytes from the HMAC
|
||||
// counter hash.
|
||||
func truncate(in []byte) int64 {
|
||||
offset := int(in[len(in)-1] & 0xF)
|
||||
p := in[offset : offset+4]
|
||||
var binCode int32
|
||||
binCode = int32((p[0] & 0x7f)) << 24
|
||||
binCode += int32((p[1] & 0xff)) << 16
|
||||
binCode += int32((p[2] & 0xff)) << 8
|
||||
binCode += int32((p[3] & 0xff))
|
||||
return int64(binCode) & 0x7FFFFFFF
|
||||
}
|
||||
|
||||
// QR generates a byte slice containing the a QR code encoded as a
|
||||
// PNG with level Q error correction.
|
||||
func (o *OATH) QR(t Type, label string) ([]byte, error) {
|
||||
u := o.URL(t, label)
|
||||
code, err := qr.Encode(u, qr.Q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return code.PNG(), nil
|
||||
}
|
||||
27
twofactor/oath_internal_test.go
Normal file
27
twofactor/oath_internal_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
var sha1Hmac = []byte{
|
||||
0x1f, 0x86, 0x98, 0x69, 0x0e,
|
||||
0x02, 0xca, 0x16, 0x61, 0x85,
|
||||
0x50, 0xef, 0x7f, 0x19, 0xda,
|
||||
0x8e, 0x94, 0x5b, 0x55, 0x5a,
|
||||
}
|
||||
|
||||
var truncExpect int64 = 0x50ef7f19
|
||||
|
||||
// This test runs through the truncation example given in the RFC.
|
||||
func TestTruncate(t *testing.T) {
|
||||
if result := truncate(sha1Hmac); result != truncExpect {
|
||||
t.Fatalf("hotp: expected truncate -> %d, saw %d\n",
|
||||
truncExpect, result)
|
||||
}
|
||||
|
||||
sha1Hmac[19]++
|
||||
if result := truncate(sha1Hmac); result == truncExpect {
|
||||
t.Fatal("hotp: expected truncation to fail")
|
||||
}
|
||||
}
|
||||
86
twofactor/otp.go
Normal file
86
twofactor/otp.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type Type uint
|
||||
|
||||
const (
|
||||
OATH_HOTP = iota
|
||||
OATH_TOTP
|
||||
)
|
||||
|
||||
// PRNG is an io.Reader that provides a cryptographically secure
|
||||
// random byte stream.
|
||||
var PRNG = rand.Reader
|
||||
|
||||
var (
|
||||
ErrInvalidURL = errors.New("twofactor: invalid URL")
|
||||
ErrInvalidAlgo = errors.New("twofactor: invalid algorithm")
|
||||
)
|
||||
|
||||
// OTP represents a one-time password token -- whether a
|
||||
// software taken (as in the case of Google Authenticator) or a
|
||||
// hardware token (as in the case of a YubiKey).
|
||||
type OTP interface {
|
||||
// Returns the current counter value; the meaning of the
|
||||
// returned value is algorithm-specific.
|
||||
Counter() uint64
|
||||
|
||||
// Set the counter to a specific value.
|
||||
SetCounter(uint64)
|
||||
|
||||
// the secret key contained in the OTP
|
||||
Key() []byte
|
||||
|
||||
// generate a new OTP
|
||||
OTP() string
|
||||
|
||||
// the output size of the OTP
|
||||
Size() int
|
||||
|
||||
// the hash function used by the OTP
|
||||
Hash() func() hash.Hash
|
||||
|
||||
// Returns the type of this OTP.
|
||||
Type() Type
|
||||
}
|
||||
|
||||
func otpString(otp OTP) string {
|
||||
var typeName string
|
||||
switch otp.Type() {
|
||||
case OATH_HOTP:
|
||||
typeName = "OATH-HOTP"
|
||||
case OATH_TOTP:
|
||||
typeName = "OATH-TOTP"
|
||||
default:
|
||||
typeName = "UNKNOWN"
|
||||
}
|
||||
return fmt.Sprintf("%s, %d", typeName, otp.Size())
|
||||
}
|
||||
|
||||
// FromURL constructs a new OTP token from a URL string.
|
||||
func FromURL(otpURL string) (OTP, string, error) {
|
||||
u, err := url.Parse(otpURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if u.Scheme != "otpauth" {
|
||||
return nil, "", ErrInvalidURL
|
||||
}
|
||||
|
||||
switch u.Host {
|
||||
case "totp":
|
||||
return totpFromURL(u)
|
||||
case "hotp":
|
||||
return hotpFromURL(u)
|
||||
default:
|
||||
return nil, "", ErrInvalidURL
|
||||
}
|
||||
}
|
||||
126
twofactor/otp_internal_test.go
Normal file
126
twofactor/otp_internal_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHOTPString(t *testing.T) {
|
||||
hotp := NewHOTP(nil, 0, 6)
|
||||
hotpString := otpString(hotp)
|
||||
if hotpString != "OATH-HOTP, 6" {
|
||||
t.Fatal("twofactor: invalid OTP string")
|
||||
}
|
||||
}
|
||||
|
||||
// This test generates a new OTP, outputs the URL for that OTP,
|
||||
// and attempts to parse that URL. It verifies that the two OTPs
|
||||
// are the same, and that they produce the same output.
|
||||
func TestURL(t *testing.T) {
|
||||
var ident = "testuser@foo"
|
||||
otp := NewHOTP(testKey, 0, 6)
|
||||
url := otp.URL("testuser@foo")
|
||||
otp2, id, err := FromURL(url)
|
||||
switch {
|
||||
case err != nil:
|
||||
t.Fatal("hotp: failed to parse HOTP URL\n")
|
||||
case id != ident:
|
||||
t.Logf("hotp: bad label\n")
|
||||
t.Logf("\texpected: %s\n", ident)
|
||||
t.Fatalf("\t actual: %s\n", id)
|
||||
case otp2.Counter() != otp.Counter():
|
||||
t.Logf("hotp: OTP counters aren't synced\n")
|
||||
t.Logf("\toriginal: %d\n", otp.Counter())
|
||||
t.Fatalf("\t second: %d\n", otp2.Counter())
|
||||
}
|
||||
|
||||
code1 := otp.OTP()
|
||||
code2 := otp2.OTP()
|
||||
if code1 != code2 {
|
||||
t.Logf("hotp: mismatched OTPs\n")
|
||||
t.Logf("\texpected: %s\n", code1)
|
||||
t.Fatalf("\t actual: %s\n", code2)
|
||||
}
|
||||
|
||||
// There's not much we can do test the QR code, except to
|
||||
// ensure it doesn't fail.
|
||||
_, err = otp.QR(ident)
|
||||
if err != nil {
|
||||
t.Fatalf("hotp: failed to generate QR code PNG (%v)\n", err)
|
||||
}
|
||||
|
||||
// This should fail because the maximum size of an alphanumeric
|
||||
// QR code with the lowest-level of error correction should
|
||||
// max out at 4296 bytes. 8k may be a bit overkill... but it
|
||||
// gets the job done. The value is read from the PRNG to
|
||||
// increase the likelihood that the returned data is
|
||||
// uncompressible.
|
||||
var tooBigIdent = make([]byte, 8192)
|
||||
_, err = io.ReadFull(PRNG, tooBigIdent)
|
||||
if err != nil {
|
||||
t.Fatalf("hotp: failed to read identity (%v)\n", err)
|
||||
} else if _, err = otp.QR(string(tooBigIdent)); err == nil {
|
||||
t.Fatal("hotp: QR code should fail to encode oversized URL")
|
||||
}
|
||||
}
|
||||
|
||||
// This test makes sure we can generate codes for padded and non-padded
|
||||
// entries.
|
||||
func TestPaddedURL(t *testing.T) {
|
||||
var urlList = []string{
|
||||
"otpauth://hotp/?secret=ME",
|
||||
"otpauth://hotp/?secret=MEFR",
|
||||
"otpauth://hotp/?secret=MFRGG",
|
||||
"otpauth://hotp/?secret=MFRGGZA",
|
||||
"otpauth://hotp/?secret=a6mryljlbufszudtjdt42nh5by=======",
|
||||
"otpauth://hotp/?secret=a6mryljlbufszudtjdt42nh5by",
|
||||
"otpauth://hotp/?secret=a6mryljlbufszudtjdt42nh5by%3D%3D%3D%3D%3D%3D%3D",
|
||||
}
|
||||
var codeList = []string{
|
||||
"413198",
|
||||
"770938",
|
||||
"670717",
|
||||
"402378",
|
||||
"069864",
|
||||
"069864",
|
||||
"069864",
|
||||
}
|
||||
|
||||
for i := range urlList {
|
||||
if o, id, err := FromURL(urlList[i]); err != nil {
|
||||
t.Log("hotp: URL should have parsed successfully (id=", id, ")")
|
||||
t.Logf("\turl was: %s\n", urlList[i])
|
||||
t.Fatalf("\t%s, %s\n", o.OTP(), id)
|
||||
} else {
|
||||
code2 := o.OTP()
|
||||
if code2 != codeList[i] {
|
||||
t.Logf("hotp: mismatched OTPs\n")
|
||||
t.Logf("\texpected: %s\n", codeList[i])
|
||||
t.Fatalf("\t actual: %s\n", code2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This test attempts a variety of invalid urls against the parser
|
||||
// to ensure they fail.
|
||||
func TestBadURL(t *testing.T) {
|
||||
var urlList = []string{
|
||||
"http://google.com",
|
||||
"",
|
||||
"-",
|
||||
"foo",
|
||||
"otpauth:/foo/bar/baz",
|
||||
"://",
|
||||
"otpauth://hotp/?digits=",
|
||||
"otpauth://hotp/?secret=MFRGGZDF&digits=ABCD",
|
||||
"otpauth://hotp/?secret=MFRGGZDF&counter=ABCD",
|
||||
}
|
||||
|
||||
for i := range urlList {
|
||||
if _, _, err := FromURL(urlList[i]); err == nil {
|
||||
t.Log("hotp: URL should not have parsed successfully")
|
||||
t.Fatalf("\turl was: %s\n", urlList[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
172
twofactor/totp.go
Normal file
172
twofactor/totp.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/sha1" // #nosec G505 - required by RFC
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/base32"
|
||||
"hash"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
)
|
||||
|
||||
var timeSource = clock.New()
|
||||
|
||||
// TOTP represents an RFC 6238 Time-based One-Time Password instance.
|
||||
type TOTP struct {
|
||||
*OATH
|
||||
step uint64
|
||||
}
|
||||
|
||||
// NewTOTP takes a new key, a starting time, a step, the number of
|
||||
// digits of output (typically 6 or 8) and the hash algorithm to
|
||||
// use, and builds a new OTP.
|
||||
func NewTOTP(key []byte, start uint64, step uint64, digits int, algo crypto.Hash) *TOTP {
|
||||
h := hashFromAlgo(algo)
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &TOTP{
|
||||
OATH: &OATH{
|
||||
key: key,
|
||||
counter: start,
|
||||
size: digits,
|
||||
hash: h,
|
||||
algo: algo,
|
||||
},
|
||||
step: step,
|
||||
}
|
||||
}
|
||||
|
||||
// NewGoogleTOTP takes a secret as a base32-encoded string and
|
||||
// returns an appropriate Google Authenticator TOTP instance.
|
||||
func NewGoogleTOTP(secret string) (*TOTP, error) {
|
||||
key, err := base32.StdEncoding.DecodeString(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewTOTP(key, 0, 30, 6, crypto.SHA1), nil
|
||||
}
|
||||
|
||||
// NewTOTPSHA1 will build a new TOTP using SHA-1.
|
||||
func NewTOTPSHA1(key []byte, start uint64, step uint64, digits int) *TOTP {
|
||||
return NewTOTP(key, start, step, digits, crypto.SHA1)
|
||||
}
|
||||
|
||||
// Type returns OATH_TOTP.
|
||||
func (otp *TOTP) Type() Type {
|
||||
return OATH_TOTP
|
||||
}
|
||||
|
||||
func (otp *TOTP) otp(counter uint64) string {
|
||||
return otp.OATH.OTP(counter)
|
||||
}
|
||||
|
||||
// OTP returns the OTP for the current timestep.
|
||||
func (otp *TOTP) OTP() string {
|
||||
return otp.otp(otp.OTPCounter())
|
||||
}
|
||||
|
||||
// URL returns a TOTP URL (i.e. for putting in a QR code).
|
||||
func (otp *TOTP) URL(label string) string {
|
||||
return otp.OATH.URL(otp.Type(), label)
|
||||
}
|
||||
|
||||
// SetProvider sets up the provider component of the OTP URL.
|
||||
func (otp *TOTP) SetProvider(provider string) {
|
||||
otp.provider = provider
|
||||
}
|
||||
|
||||
func (otp *TOTP) otpCounter(t uint64) uint64 {
|
||||
return (t - otp.counter) / otp.step
|
||||
}
|
||||
|
||||
// OTPCounter returns the current time value for the OTP.
|
||||
func (otp *TOTP) OTPCounter() uint64 {
|
||||
return otp.otpCounter(uint64(timeSource.Now().Unix() & 0x7FFFFFFF)) //#nosec G115 - masked out overflow bits
|
||||
}
|
||||
|
||||
func hashFromAlgo(algo crypto.Hash) func() hash.Hash {
|
||||
switch algo {
|
||||
case crypto.SHA1:
|
||||
return sha1.New
|
||||
case crypto.SHA256:
|
||||
return sha256.New
|
||||
case crypto.SHA512:
|
||||
return sha512.New
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateGoogleTOTP produces a new TOTP token with the defaults expected by
|
||||
// Google Authenticator.
|
||||
func GenerateGoogleTOTP() *TOTP {
|
||||
key := make([]byte, sha1.Size)
|
||||
if _, err := io.ReadFull(PRNG, key); err != nil {
|
||||
return nil
|
||||
}
|
||||
return NewTOTP(key, 0, 30, 6, crypto.SHA1)
|
||||
}
|
||||
|
||||
func totpFromURL(u *url.URL) (*TOTP, string, error) {
|
||||
label := u.Path[1:]
|
||||
v := u.Query()
|
||||
|
||||
secret := strings.ToUpper(v.Get("secret"))
|
||||
if secret == "" {
|
||||
return nil, "", ErrInvalidURL
|
||||
}
|
||||
|
||||
var algo = crypto.SHA1
|
||||
if algorithm := v.Get("algorithm"); algorithm != "" {
|
||||
switch {
|
||||
case strings.EqualFold(algorithm, "SHA256"):
|
||||
algo = crypto.SHA256
|
||||
case strings.EqualFold(algorithm, "SHA512"):
|
||||
algo = crypto.SHA512
|
||||
case !strings.EqualFold(algorithm, "SHA1"):
|
||||
return nil, "", ErrInvalidAlgo
|
||||
}
|
||||
}
|
||||
|
||||
var digits = 6
|
||||
if sdigit := v.Get("digits"); sdigit != "" {
|
||||
tmpDigits, err := strconv.ParseInt(sdigit, 10, 8)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
digits = int(tmpDigits)
|
||||
}
|
||||
|
||||
var period uint64 = 30
|
||||
if speriod := v.Get("period"); speriod != "" {
|
||||
var err error
|
||||
period, err = strconv.ParseUint(speriod, 10, 64)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
|
||||
key, err := base32.StdEncoding.DecodeString(Pad(secret))
|
||||
if err != nil {
|
||||
// assume secret isn't base32 encoded
|
||||
key = []byte(secret)
|
||||
}
|
||||
otp := NewTOTP(key, 0, period, digits, algo)
|
||||
return otp, label, nil
|
||||
}
|
||||
|
||||
// QR generates a new TOTP QR code.
|
||||
func (otp *TOTP) QR(label string) ([]byte, error) {
|
||||
return otp.OATH.QR(otp.Type(), label)
|
||||
}
|
||||
|
||||
func SetClock(c clock.Clock) {
|
||||
timeSource = c
|
||||
}
|
||||
85
twofactor/totp_internal_test.go
Normal file
85
twofactor/totp_internal_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
)
|
||||
|
||||
var rfcTotpKey = map[crypto.Hash][]byte{
|
||||
crypto.SHA1: []byte("12345678901234567890"),
|
||||
crypto.SHA256: []byte("12345678901234567890123456789012"),
|
||||
crypto.SHA512: []byte("1234567890123456789012345678901234567890123456789012345678901234"),
|
||||
}
|
||||
|
||||
var rfcTotpStep uint64 = 30
|
||||
|
||||
var rfcTotpTests = []struct {
|
||||
Time uint64
|
||||
Code string
|
||||
T uint64
|
||||
Algo crypto.Hash
|
||||
}{
|
||||
{59, "94287082", 1, crypto.SHA1},
|
||||
{59, "46119246", 1, crypto.SHA256},
|
||||
{59, "90693936", 1, crypto.SHA512},
|
||||
{1111111109, "07081804", 37037036, crypto.SHA1},
|
||||
{1111111109, "68084774", 37037036, crypto.SHA256},
|
||||
{1111111109, "25091201", 37037036, crypto.SHA512},
|
||||
{1111111111, "14050471", 37037037, crypto.SHA1},
|
||||
{1111111111, "67062674", 37037037, crypto.SHA256},
|
||||
{1111111111, "99943326", 37037037, crypto.SHA512},
|
||||
{1234567890, "89005924", 41152263, crypto.SHA1},
|
||||
{1234567890, "91819424", 41152263, crypto.SHA256},
|
||||
{1234567890, "93441116", 41152263, crypto.SHA512},
|
||||
{2000000000, "69279037", 66666666, crypto.SHA1},
|
||||
{2000000000, "90698825", 66666666, crypto.SHA256},
|
||||
{2000000000, "38618901", 66666666, crypto.SHA512},
|
||||
{20000000000, "65353130", 666666666, crypto.SHA1},
|
||||
{20000000000, "77737706", 666666666, crypto.SHA256},
|
||||
{20000000000, "47863826", 666666666, crypto.SHA512},
|
||||
}
|
||||
|
||||
func TestTotpRFC(t *testing.T) {
|
||||
for _, tc := range rfcTotpTests {
|
||||
otp := NewTOTP(rfcTotpKey[tc.Algo], 0, rfcTotpStep, 8, tc.Algo)
|
||||
if otp.otpCounter(tc.Time) != tc.T {
|
||||
t.Logf("twofactor: invalid TOTP (t=%d, h=%d)\n", tc.Time, tc.Algo)
|
||||
t.Logf("\texpected: %d\n", tc.T)
|
||||
t.Errorf("\t actual: %d\n", otp.otpCounter(tc.Time))
|
||||
}
|
||||
|
||||
if code := otp.otp(otp.otpCounter(tc.Time)); code != tc.Code {
|
||||
t.Logf("twofactor: invalid TOTP (t=%d, h=%d)\n", tc.Time, tc.Algo)
|
||||
t.Logf("\texpected: %s\n", tc.Code)
|
||||
t.Errorf("\t actual: %s\n", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPTime(t *testing.T) {
|
||||
otp := GenerateGoogleTOTP()
|
||||
|
||||
testClock := clock.NewMock()
|
||||
testClock.Add(2 * time.Minute)
|
||||
SetClock(testClock)
|
||||
|
||||
code := otp.OTP()
|
||||
|
||||
testClock.Add(-1 * time.Minute)
|
||||
if newCode := otp.OTP(); newCode == code {
|
||||
t.Errorf("twofactor: TOTP: previous code %s shouldn't match code %s", newCode, code)
|
||||
}
|
||||
|
||||
testClock.Add(2 * time.Minute)
|
||||
if newCode := otp.OTP(); newCode == code {
|
||||
t.Errorf("twofactor: TOTP: future code %s shouldn't match code %s", newCode, code)
|
||||
}
|
||||
|
||||
testClock.Add(-1 * time.Minute)
|
||||
if newCode := otp.OTP(); newCode != code {
|
||||
t.Errorf("twofactor: TOTP: current code %s shouldn't match code %s", newCode, code)
|
||||
}
|
||||
}
|
||||
16
twofactor/util.go
Normal file
16
twofactor/util.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Pad calculates the number of '='s to add to our encoded string
|
||||
// to make base32.StdEncoding.DecodeString happy.
|
||||
func Pad(s string) string {
|
||||
if !strings.HasSuffix(s, "=") && len(s)%8 != 0 {
|
||||
for len(s)%8 != 0 {
|
||||
s += "="
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
51
twofactor/util_test.go
Normal file
51
twofactor/util_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package twofactor_test
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/twofactor"
|
||||
)
|
||||
|
||||
const letters = "1234567890!@#$%^&*()abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
func randString() string {
|
||||
b := make([]byte, rand.Intn(len(letters)))
|
||||
for i := range b {
|
||||
b[i] = letters[rand.Intn(len(letters))]
|
||||
}
|
||||
return base32.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func TestPadding(t *testing.T) {
|
||||
for range 300 {
|
||||
b := randString()
|
||||
origEncoding := b
|
||||
modEncoding := strings.ReplaceAll(b, "=", "")
|
||||
str, err := base32.StdEncoding.DecodeString(origEncoding)
|
||||
if err != nil {
|
||||
t.Fatal("Can't decode: ", b)
|
||||
}
|
||||
|
||||
paddedEncoding := twofactor.Pad(modEncoding)
|
||||
if origEncoding != paddedEncoding {
|
||||
t.Log("Padding failed:")
|
||||
t.Logf("Expected: '%s'", origEncoding)
|
||||
t.Fatalf("Got: '%s'", paddedEncoding)
|
||||
} else {
|
||||
var mstr []byte
|
||||
mstr, err = base32.StdEncoding.DecodeString(paddedEncoding)
|
||||
if err != nil {
|
||||
t.Fatal("Can't decode: ", paddedEncoding)
|
||||
}
|
||||
|
||||
if string(mstr) != string(str) {
|
||||
t.Log("Re-padding failed:")
|
||||
t.Logf("Expected: '%s'", str)
|
||||
t.Fatalf("Got: '%s'", mstr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user